diff --git a/README.md b/README.md index 2ecca46..7653fb2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/samples/HubDocs.Sample/Program.cs b/samples/HubDocs.Sample/Program.cs index 97e98e7..18f6581 100644 --- a/samples/HubDocs.Sample/Program.cs +++ b/samples/HubDocs.Sample/Program.cs @@ -30,7 +30,21 @@ app.MapHub("/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(); diff --git a/src/HubDocs/Extensions.cs b/src/HubDocs/Extensions.cs index 5db03c1..23cc68f 100644 --- a/src/HubDocs/Extensions.cs +++ b/src/HubDocs/Extensions.cs @@ -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 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(); @@ -439,6 +448,279 @@ private static string BuildReturnExample(MethodInfo method) return CreateExampleLiteral(unwrapped, false); } + private static Dictionary BuildHubDocsDocument(IReadOnlyList hubs) + { + return BuildHubDocsDocument(hubs, new HubDocsDocumentOptions()); + } + + private static Dictionary BuildHubDocsDocument(IReadOnlyList hubs, HubDocsDocumentOptions options) + { + var channels = new Dictionary(); + var messages = new Dictionary(); + var schemas = new Dictionary(); + + 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(); + foreach (var method in hub.Methods) + { + var messageName = $"{hub.HubName}.{method.MethodName}.Request"; + messages[messageName] = BuildMethodMessage(messageName, method, schemas.Keys); + publishMessageRefs.Add(new Dictionary + { + ["$ref"] = $"#/components/messages/{messageName}" + }); + } + + var subscribeMessageRefs = new List(); + foreach (var method in hub.ClientMethods ?? []) + { + var messageName = $"{hub.HubName}.{method.MethodName}.Event"; + messages[messageName] = BuildMethodMessage(messageName, method, schemas.Keys); + subscribeMessageRefs.Add(new Dictionary + { + ["$ref"] = $"#/components/messages/{messageName}" + }); + } + + channels[hub.Path] = new Dictionary + { + ["publish"] = new Dictionary + { + ["operationId"] = $"{hub.HubName}.publish", + ["summary"] = $"Client-to-server methods for {hub.HubName}", + ["message"] = new Dictionary + { + ["oneOf"] = publishMessageRefs + } + }, + ["subscribe"] = new Dictionary + { + ["operationId"] = $"{hub.HubName}.subscribe", + ["summary"] = $"Server-to-client methods for {hub.HubName}", + ["message"] = new Dictionary + { + ["oneOf"] = subscribeMessageRefs + } + } + }; + } + + return new Dictionary + { + ["hubdocs"] = new Dictionary + { + ["format"] = "hubdocs-1.0", + ["version"] = options.Version, + ["title"] = options.Title, + ["description"] = options.Description, + ["termsOfService"] = options.TermsOfService, + ["projectUrl"] = options.ProjectUrl, + ["contact"] = new Dictionary + { + ["name"] = options.Contact.Name, + ["email"] = options.Contact.Email, + ["url"] = options.Contact.Url + }, + ["license"] = new Dictionary + { + ["name"] = options.License.Name, + ["url"] = options.License.Url + }, + ["generatedAtUtc"] = DateTime.UtcNow.ToString("O") + }, + ["hubs"] = hubs, + ["channels"] = channels, + ["components"] = new Dictionary + { + ["messages"] = messages, + ["schemas"] = schemas + } + }; + } + + private static Dictionary BuildMethodMessage(string messageName, HubMethodMetadata method, IEnumerable knownSchemas) + { + var argumentProperties = new Dictionary(); + + foreach (var parameter in method.Parameters) + { + argumentProperties[parameter.Name] = BuildJsonSchemaForType(parameter.Type, knownSchemas, parameter.Example, parameter.IsNullable); + } + + var payloadProperties = new Dictionary + { + ["method"] = new Dictionary + { + ["type"] = "string", + ["example"] = method.MethodName + }, + ["arguments"] = new Dictionary + { + ["type"] = "object", + ["properties"] = argumentProperties + }, + ["returns"] = BuildJsonSchemaForType(method.ReturnType, knownSchemas, method.ReturnExample, false) + }; + + return new Dictionary + { + ["name"] = messageName, + ["title"] = method.Signature, + ["payload"] = new Dictionary + { + ["type"] = "object", + ["properties"] = payloadProperties, + ["required"] = new[] { "method", "arguments" } + }, + ["examples"] = new[] + { + new Dictionary + { + ["name"] = $"{method.MethodName}Example", + ["summary"] = method.Signature, + ["payload"] = new Dictionary + { + ["method"] = method.MethodName, + ["arguments"] = method.Parameters.ToDictionary(p => p.Name, p => (object?)p.Example), + ["returns"] = method.ReturnExample + } + } + } + }; + } + + private static Dictionary BuildJsonSchemaForType(string csharpType, IEnumerable 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 + { + ["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 + { + ["type"] = "array", + ["items"] = itemSchema + }; + + if (example != null) + listSchema["example"] = example; + + if (nullable) + listSchema["nullable"] = true; + + return listSchema; + } + + var schemaName = cleaned.Contains('<') + ? cleaned[..cleaned.IndexOf('<')] + : cleaned; + + if (knownSchemas.Contains(schemaName, StringComparer.Ordinal)) + { + var refSchema = new Dictionary + { + ["$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 MapPrimitiveJsonSchema(string csharpType) + { + return csharpType switch + { + "bool" => new Dictionary { ["type"] = "boolean" }, + "byte" or "sbyte" or "short" or "ushort" or "int" or "uint" or "long" or "ulong" => + new Dictionary { ["type"] = "integer", ["format"] = "int64" }, + "float" => new Dictionary { ["type"] = "number", ["format"] = "float" }, + "double" or "decimal" => new Dictionary { ["type"] = "number", ["format"] = "double" }, + "Guid" => new Dictionary { ["type"] = "string", ["format"] = "uuid" }, + "DateTime" or "DateTimeOffset" => new Dictionary { ["type"] = "string", ["format"] = "date-time" }, + "TimeSpan" => new Dictionary { ["type"] = "string" }, + "Task" or "void" => new Dictionary { ["type"] = "null" }, + _ => new Dictionary { ["type"] = "string" } + }; + } + + private static Dictionary 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 + { + ["type"] = "string", + ["enum"] = enumNames, + ["example"] = enumNames.FirstOrDefault() + }; + } + + var properties = new Dictionary(); + foreach (var property in schema.Properties ?? []) + { + properties[property.Name] = BuildJsonSchemaForType(property.Type, [], property.Example, property.IsNullable); + } + + return new Dictionary + { + ["type"] = "object", + ["properties"] = properties, + ["example"] = schema.Example + }; + } + private static string CreateExampleLiteral(Type type, bool nullable) { if (nullable) diff --git a/src/HubDocs/HubDocs.csproj b/src/HubDocs/HubDocs.csproj index 60dbc81..341727e 100644 --- a/src/HubDocs/HubDocs.csproj +++ b/src/HubDocs/HubDocs.csproj @@ -6,7 +6,7 @@ true HubDocs - 0.0.8 + 0.0.9 BerrishDev HubDocs Swagger-like documentation UI for SignalR Hubs diff --git a/src/HubDocs/HubDocsDocumentOptions.cs b/src/HubDocs/HubDocsDocumentOptions.cs new file mode 100644 index 0000000..8d55e5e --- /dev/null +++ b/src/HubDocs/HubDocsDocumentOptions.cs @@ -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; } +} diff --git a/src/HubDocs/README.md b/src/HubDocs/README.md index ec0c7be..47be966 100644 --- a/src/HubDocs/README.md +++ b/src/HubDocs/README.md @@ -133,6 +133,38 @@ Scan specific assemblies for hubs: app.AddHubDocs(typeof(ExternalHub).Assembly); ``` +### Document Metadata Options + +You can configure project metadata (Swagger-like `info`) for HubDocs JSON and UI: + +```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 also combine metadata options with explicit assemblies: + +```csharp +app.AddHubDocs(options => +{ + options.Title = "External Hubs API"; + options.Version = "2.0.0"; +}, typeof(ExternalHub).Assembly); +``` + ### Opt-in with Attribute Only hubs marked with `[HubDocs]` attribute are documented. This provides control over which hubs appear in the UI. diff --git a/src/HubDocs/wwwroot/hubdocs.html b/src/HubDocs/wwwroot/hubdocs.html index 8eeb35e..38766bd 100644 --- a/src/HubDocs/wwwroot/hubdocs.html +++ b/src/HubDocs/wwwroot/hubdocs.html @@ -60,6 +60,19 @@

HubDocs

SignalR Hubs Explorer

+
@@ -90,6 +103,7 @@

HubDocs

+
@@ -125,9 +139,55 @@

HubDocs