diff --git a/samples/HubDocs.Sample/Hubs/ChatHub.cs b/samples/HubDocs.Sample/Hubs/ChatHub.cs index 6fe4ea6..3e09dad 100644 --- a/samples/HubDocs.Sample/Hubs/ChatHub.cs +++ b/samples/HubDocs.Sample/Hubs/ChatHub.cs @@ -6,10 +6,27 @@ public interface IChatClient { Task Connected(string connectionId); Task ReceiveMessage(string user, string message); + Task ReceiveRichMessage(ChatMessagePayload payload); Task UserJoined(string connectionId); Task UserLeft(string connectionId); } +public enum MessagePriority +{ + Low = 0, + Normal = 1, + High = 2 +} + +public class ChatMessagePayload +{ + public string User { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public MessagePriority Priority { get; set; } = MessagePriority.Normal; + public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; + public List Tags { get; set; } = []; +} + [HubDocs] public class ChatHub : Hub { @@ -23,6 +40,11 @@ public async Task SendMessage(string user, string message) await Clients.All.ReceiveMessage(user, message); } + public async Task SendRichMessage(ChatMessagePayload payload) + { + await Clients.All.ReceiveRichMessage(payload); + } + public async Task JoinRoom(string roomName, List roles) { await Groups.AddToGroupAsync(Context.ConnectionId, roomName); diff --git a/src/HubDocs/Extensions.cs b/src/HubDocs/Extensions.cs index caf9359..5db03c1 100644 --- a/src/HubDocs/Extensions.cs +++ b/src/HubDocs/Extensions.cs @@ -1,4 +1,3 @@ -using System.Collections.ObjectModel; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -10,6 +9,8 @@ namespace HubDocs; public static class Extensions { + private static readonly NullabilityInfoContext NullabilityContext = new(); + public static WebApplication AddHubDocs(this WebApplication app, params Assembly[] additionalAssemblies) { app.MapGet("/hubdocs/hubdocs.json", () => @@ -57,7 +58,6 @@ private static Dictionary GetHubRoutesFromEndpoints(WebApplication foreach (var endpoint in dataSource.Endpoints) { if (endpoint is not RouteEndpoint routeEndpoint) continue; - // Look for SignalR hub metadata foreach (var metadata in routeEndpoint.Metadata) { var metadataType = metadata.GetType(); @@ -102,11 +102,11 @@ private static IEnumerable DiscoverSignalRHubs(Dictionary { var attribute = hubType.GetCustomAttribute(); - - // Only include hubs with [HubDocs] attribute that are registered if (attribute == null || !hubRoutes.ContainsKey(hubType)) return null; + var schemaRegistry = new Dictionary(); + var hubMetadata = new HubMetadata { HubName = hubType.Name, @@ -115,27 +115,21 @@ private static IEnumerable DiscoverSignalRHubs(Dictionary g.First()) - .Select(m => new HubMethodMetadata - { - MethodName = m.Name, - ParameterTypes = [.. m.GetParameters().Select(FormatParameter)], - ReturnType = FormatType(m.ReturnType) - })] + .Select(m => BuildHubMethodMetadata(m, schemaRegistry))], + Schemas = [] }; - if (hubType.BaseType?.IsGenericType != true || - hubType.BaseType.GetGenericTypeDefinition() != typeof(Hub<>)) return hubMetadata; + if (hubType.BaseType?.IsGenericType == true && + hubType.BaseType.GetGenericTypeDefinition() == typeof(Hub<>)) + { + var clientInterface = hubType.BaseType.GetGenericArguments()[0]; - var clientInterface = hubType.BaseType.GetGenericArguments()[0]; + hubMetadata.ClientInterfaceName = clientInterface.FullName!; + hubMetadata.ClientMethods = [.. clientInterface.GetMethods() + .Select(m => BuildHubMethodMetadata(m, schemaRegistry))]; + } - hubMetadata.ClientInterfaceName = clientInterface.FullName!; - hubMetadata.ClientMethods = [.. clientInterface.GetMethods() - .Select(m => new HubMethodMetadata - { - MethodName = m.Name, - ParameterTypes = [.. m.GetParameters().Select(FormatParameter)], - ReturnType = FormatType(m.ReturnType) - })]; + hubMetadata.Schemas = [.. schemaRegistry.Values.OrderBy(s => s.Name)]; return hubMetadata; }) @@ -144,6 +138,43 @@ private static IEnumerable DiscoverSignalRHubs(Dictionary x.HubName); } + private static HubMethodMetadata BuildHubMethodMetadata(MethodInfo method, Dictionary schemaRegistry) + { + var parameters = method.GetParameters() + .Select(p => BuildParameterMetadata(p, schemaRegistry)) + .ToList(); + + RegisterSchemaFromType(method.ReturnType, schemaRegistry); + + var returnType = FormatMethodReturnType(method); + var signatureParams = string.Join(", ", parameters.Select(p => $"{p.Type} {p.Name}")); + + return new HubMethodMetadata + { + MethodName = method.Name, + Signature = $"{returnType} {method.Name}({signatureParams})", + ParameterTypes = [.. parameters.Select(p => p.Type)], + Parameters = parameters, + ReturnType = returnType, + ExampleInvocation = $"{method.Name}({string.Join(", ", parameters.Select(p => p.Example))})", + ReturnExample = BuildReturnExample(method) + }; + } + + private static HubParameterMetadata BuildParameterMetadata(ParameterInfo parameter, Dictionary schemaRegistry) + { + var isNullable = IsNullable(parameter); + RegisterSchemaFromType(parameter.ParameterType, schemaRegistry); + + return new HubParameterMetadata + { + Name = parameter.Name ?? "value", + Type = FormatTypeWithNullability(parameter.ParameterType, isNullable), + IsNullable = isNullable, + Example = CreateExampleLiteral(parameter.ParameterType, isNullable) + }; + } + private static IEnumerable GetAllPublicHubMethods(Type type) { var allMethods = new List(); @@ -186,25 +217,264 @@ private static string GetMethodSignature(MethodInfo method) return $"{method.Name}({string.Join(",", paramTypes)})"; } + private static string FormatMethodReturnType(MethodInfo method) + { + var returnType = method.ReturnType; + if (returnType == typeof(void)) + return "void"; + + if (returnType == typeof(Task)) + return "Task"; + + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var inner = returnType.GetGenericArguments()[0]; + return $"Task<{FormatType(inner)}>"; + } + + return FormatType(returnType); + } + private static string FormatType(Type type) { + return FormatTypeWithNullability(type, false); + } + + private static string FormatTypeWithNullability(Type type, bool nullableReference) + { + var nullableUnderlying = Nullable.GetUnderlyingType(type); + if (nullableUnderlying != null) + return $"{FormatType(nullableUnderlying)}?"; + + if (type.IsArray) + return $"{FormatType(type.GetElementType()!)}[]"; + + if (TryGetKeywordAlias(type, out var alias)) + return alias; + if (!type.IsGenericType) + { + if (!type.IsValueType && nullableReference) + return $"{type.Name}?"; + return type.Name; + } var typeName = type.Name[..type.Name.IndexOf('`')]; var genericArgs = type.GetGenericArguments() - .Select(FormatType); - return $"{typeName}<{string.Join(", ", genericArgs)}>"; + .Select(arg => FormatType(arg)); + var formatted = $"{typeName}<{string.Join(", ", genericArgs)}>"; + + if (!type.IsValueType && nullableReference) + return $"{formatted}?"; + + return formatted; + } + + private static bool TryGetKeywordAlias(Type type, out string alias) + { + alias = type.Name; + + if (type == typeof(bool)) alias = "bool"; + else if (type == typeof(byte)) alias = "byte"; + else if (type == typeof(sbyte)) alias = "sbyte"; + else if (type == typeof(short)) alias = "short"; + else if (type == typeof(ushort)) alias = "ushort"; + else if (type == typeof(int)) alias = "int"; + else if (type == typeof(uint)) alias = "uint"; + else if (type == typeof(long)) alias = "long"; + else if (type == typeof(ulong)) alias = "ulong"; + else if (type == typeof(float)) alias = "float"; + else if (type == typeof(double)) alias = "double"; + else if (type == typeof(decimal)) alias = "decimal"; + else if (type == typeof(char)) alias = "char"; + else if (type == typeof(string)) alias = "string"; + else if (type == typeof(object)) alias = "object"; + else return false; + + return true; } private static string FormatParameter(ParameterInfo parameter) { - var type = parameter.ParameterType; - var typeName = FormatType(type); + var isNullable = IsNullable(parameter); + return FormatTypeWithNullability(parameter.ParameterType, isNullable); + } + + private static bool IsSimpleType(Type type) + { + var normalized = NormalizeType(type); + + if (normalized.IsEnum) + return false; - bool isNullable = IsNullable(parameter); + if (TryGetKeywordAlias(normalized, out _)) + return true; - return isNullable ? $"{typeName}?" : typeName; + return normalized == typeof(DateTime) || + normalized == typeof(DateTimeOffset) || + normalized == typeof(Guid) || + normalized == typeof(TimeSpan); + } + + private static Type NormalizeType(Type type) + { + if (type == typeof(Task)) + return typeof(void); + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + return type.GetGenericArguments()[0]; + + if (Nullable.GetUnderlyingType(type) is Type nullableUnderlying) + return nullableUnderlying; + + if (type.IsArray) + return type.GetElementType()!; + + if (type.IsGenericType && type != typeof(string)) + { + var genericDefinition = type.GetGenericTypeDefinition(); + if (genericDefinition == typeof(IEnumerable<>) || + genericDefinition == typeof(ICollection<>) || + genericDefinition == typeof(IList<>) || + genericDefinition == typeof(List<>) || + genericDefinition == typeof(IReadOnlyList<>)) + { + return type.GetGenericArguments()[0]; + } + } + + return type; + } + + private static void RegisterSchemaFromType(Type type, Dictionary schemaRegistry) + { + RegisterSchemaFromType(type, schemaRegistry, new HashSet()); + } + + private static void RegisterSchemaFromType(Type type, Dictionary schemaRegistry, HashSet visiting) + { + var normalized = NormalizeType(type); + if (normalized == typeof(void) || IsSimpleType(normalized)) + return; + + // Skip framework/system types (for example Exception) to keep schema output focused on app DTOs. + if (normalized.Namespace?.StartsWith("System", StringComparison.Ordinal) == true) + return; + + var key = normalized.FullName ?? normalized.Name; + if (visiting.Contains(key) || schemaRegistry.ContainsKey(key)) + return; + + visiting.Add(key); + + if (normalized.IsEnum) + { + var names = Enum.GetNames(normalized) + .Select(n => $"{n} = {Convert.ToInt64(Enum.Parse(normalized, n))}") + .ToList(); + + schemaRegistry[key] = new HubTypeSchemaMetadata + { + Name = normalized.Name, + FullName = key, + Kind = "enum", + EnumValues = names, + Example = names.FirstOrDefault() ?? string.Empty + }; + + return; + } + + if (normalized.IsClass || (normalized.IsValueType && !normalized.IsPrimitive)) + { + var properties = normalized + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead) + .Select(p => + { + var nullability = NullabilityContext.Create(p); + var isNullable = nullability.ReadState == NullabilityState.Nullable || + Nullable.GetUnderlyingType(p.PropertyType) != null; + + RegisterSchemaFromType(p.PropertyType, schemaRegistry, visiting); + + return new HubSchemaPropertyMetadata + { + Name = p.Name, + Type = FormatTypeWithNullability(p.PropertyType, isNullable), + IsNullable = isNullable, + Example = CreateExampleLiteral(p.PropertyType, isNullable) + }; + }) + .ToList(); + + schemaRegistry[key] = new HubTypeSchemaMetadata + { + Name = normalized.Name, + FullName = key, + Kind = "object", + Properties = properties, + Example = CreateObjectExample(properties) + }; + } + } + + private static string CreateObjectExample(IReadOnlyList properties) + { + if (properties.Count == 0) + return "{}"; + + var lines = properties.Select(p => $" \"{p.Name}\": {p.Example}"); + return "{\n" + string.Join(",\n", lines) + "\n}"; + } + + private static string BuildReturnExample(MethodInfo method) + { + var returnType = method.ReturnType; + if (returnType == typeof(void) || returnType == typeof(Task)) + return "void"; + + var unwrapped = NormalizeType(returnType); + return CreateExampleLiteral(unwrapped, false); + } + + private static string CreateExampleLiteral(Type type, bool nullable) + { + if (nullable) + return "null"; + + var normalized = NormalizeType(type); + + if (normalized == typeof(string)) return "\"example\""; + if (normalized == typeof(bool)) return "true"; + if (normalized == typeof(byte) || normalized == typeof(sbyte) || normalized == typeof(short) || + normalized == typeof(ushort) || normalized == typeof(int) || normalized == typeof(uint) || + normalized == typeof(long) || normalized == typeof(ulong)) return "123"; + if (normalized == typeof(float) || normalized == typeof(double) || normalized == typeof(decimal)) return "12.34"; + if (normalized == typeof(char)) return "\"A\""; + if (normalized == typeof(Guid)) return "\"3fa85f64-5717-4562-b3fc-2c963f66afa6\""; + if (normalized == typeof(DateTime) || normalized == typeof(DateTimeOffset)) return "\"2026-01-01T00:00:00Z\""; + if (normalized == typeof(TimeSpan)) return "\"00:30:00\""; + + if (normalized.IsEnum) + { + var first = Enum.GetNames(normalized).FirstOrDefault(); + return first is null ? "0" : $"\"{first}\""; + } + + if (type.IsArray || + (type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + type.GetGenericTypeDefinition() == typeof(ICollection<>) || + type.GetGenericTypeDefinition() == typeof(IList<>) || + type.GetGenericTypeDefinition() == typeof(List<>) || + type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)))) + { + return "[]"; + } + + return "{}"; } private static bool IsNullable(ParameterInfo parameter) @@ -214,6 +484,10 @@ private static bool IsNullable(ParameterInfo parameter) if (Nullable.GetUnderlyingType(type) != null) return true; + var nullability = NullabilityContext.Create(parameter); + if (nullability.ReadState == NullabilityState.Nullable) + return true; + var nullableAttr = parameter .CustomAttributes .FirstOrDefault(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute"); @@ -225,11 +499,11 @@ private static bool IsNullable(ParameterInfo parameter) if (arg.ArgumentType == typeof(byte) && (byte)arg.Value! == 2) return true; - if (arg.Value is ReadOnlyCollection args && + if (arg.Value is IReadOnlyCollection args && args.FirstOrDefault().Value is byte and 2) return true; } return false; } -} \ No newline at end of file +} diff --git a/src/HubDocs/HubMetadata.cs b/src/HubDocs/HubMetadata.cs index 95cdd0b..87ff9f7 100644 --- a/src/HubDocs/HubMetadata.cs +++ b/src/HubDocs/HubMetadata.cs @@ -6,6 +6,7 @@ public class HubMetadata public string HubFullName { get; init; } = null!; public string? Path { get; init; } = null!; public List Methods { get; init; } = []; + public List Schemas { get; set; } = []; public string? ClientInterfaceName { get; set; } public List? ClientMethods { get; set; } diff --git a/src/HubDocs/HubMethodMetadata.cs b/src/HubDocs/HubMethodMetadata.cs index 603e031..facd310 100644 --- a/src/HubDocs/HubMethodMetadata.cs +++ b/src/HubDocs/HubMethodMetadata.cs @@ -3,6 +3,18 @@ namespace HubDocs; public class HubMethodMetadata { public string MethodName { get; init; } = null!; + public string Signature { get; init; } = null!; public List ParameterTypes { get; init; } = []; + public List Parameters { get; init; } = []; public string ReturnType { get; init; } = null!; + public string? ExampleInvocation { get; init; } + public string? ReturnExample { get; init; } +} + +public class HubParameterMetadata +{ + public string Name { get; init; } = null!; + public string Type { get; init; } = null!; + public bool IsNullable { get; init; } + public string Example { get; init; } = null!; } \ No newline at end of file diff --git a/src/HubDocs/HubTypeSchemaMetadata.cs b/src/HubDocs/HubTypeSchemaMetadata.cs new file mode 100644 index 0000000..f99da5a --- /dev/null +++ b/src/HubDocs/HubTypeSchemaMetadata.cs @@ -0,0 +1,19 @@ +namespace HubDocs; + +public class HubTypeSchemaMetadata +{ + public string Name { get; init; } = null!; + public string FullName { get; init; } = null!; + public string Kind { get; init; } = null!; + public List? EnumValues { get; init; } + public List? Properties { get; init; } + public string Example { get; init; } = null!; +} + +public class HubSchemaPropertyMetadata +{ + public string Name { get; init; } = null!; + public string Type { get; init; } = null!; + public bool IsNullable { get; init; } + public string Example { get; init; } = null!; +} diff --git a/src/HubDocs/wwwroot/hubdocs.html b/src/HubDocs/wwwroot/hubdocs.html index 6a3584d..8eeb35e 100644 --- a/src/HubDocs/wwwroot/hubdocs.html +++ b/src/HubDocs/wwwroot/hubdocs.html @@ -189,10 +189,18 @@

HubDocs

const methods = hub.methods .map((m, methodIdx) => { - const params = m.parameterTypes - .map((p, i) => `${p}`) + const typedParameters = Array.isArray(m.parameters) + ? m.parameters + : []; + + const params = (typedParameters.length > 0 + ? typedParameters.map((p) => `${p.type} ${p.name}`) + : m.parameterTypes) + .map((p) => `${p}`) .join(", "); + const signature = m.signature || `${m.returnType} ${m.methodName}(${params})`; + // If no path, do not show Try it button const tryItButton = hasPath ? `` @@ -200,10 +208,10 @@

HubDocs

const tryItFormId = `tryit-form-${hub.hubName}-${m.methodName}-${methodIdx}`; const resultId = `tryit-result-${hub.hubName}-${m.methodName}-${methodIdx}`; - const paramInputs = m.parameterTypes + const paramInputs = (typedParameters.length > 0 ? typedParameters : m.parameterTypes.map((p, idx) => ({ type: p, name: `param${idx}`, example: "" }))) .map( (p, i) => - `` + `` ) .join(""); @@ -220,6 +228,10 @@

HubDocs

${m.methodName}

+ Signature: + ${signature} +
+
Parameters: (${params})
@@ -227,6 +239,8 @@

${m.methodName}

Returns: ${m.returnType} + ${m.exampleInvocation ? `
Example Call:
${m.exampleInvocation}
` : ""} + ${m.returnExample ? `
Example Result:
${m.returnExample}
` : ""} ${tryItButton} @@ -238,9 +252,15 @@

${m.methodName}

const clientMethods = (hub.clientMethods || []) .map((m) => { - const params = m.parameterTypes - .map((p) => `${p}`) + const typedParameters = Array.isArray(m.parameters) + ? m.parameters + : []; + const params = (typedParameters.length > 0 + ? typedParameters.map((p) => `${p.type} ${p.name}`) + : m.parameterTypes) + .map((p) => `${p}`) .join(", "); + const signature = m.signature || `${m.returnType} ${m.methodName}(${params})`; return `
@@ -248,6 +268,10 @@

${m.methodName}

${m.methodName}

+
+ Signature: + ${signature} +
Parameters: (${params}) @@ -256,12 +280,29 @@

${m.methodName}

Returns: ${m.returnType}
+ ${m.exampleInvocation ? `
Example Call:
${m.exampleInvocation}
` : ""}
`; }) .join(""); + const schemas = (hub.schemas || []) + .map((s) => { + const enumBlock = s.kind === "enum" && Array.isArray(s.enumValues) + ? `
Enum Values
${s.enumValues.join("\n")}
` + : ""; + + const propertyBlock = s.kind === "object" && Array.isArray(s.properties) + ? `
Properties
${s.properties + .map((p) => `
${p.type} ${p.name}${p.isNullable ? ' (nullable)' : ""}
`) + .join("")}
` + : ""; + + return `
${s.name}
${s.kind}
${s.fullName}
${enumBlock}${propertyBlock}
Example
${s.example}
`; + }) + .join(""); + hubCard.innerHTML = `
@@ -286,6 +327,7 @@

📨 Client Methods

${clientMethods}
` : "" : ""} + ${schemas ? `

🧩 Schemas

${schemas}
` : ""}
`; diff --git a/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs b/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs index ef32409..5d72c3a 100644 --- a/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs +++ b/tests/HubDocs.UnitTests/ExtensionsInternalTests.cs @@ -125,7 +125,7 @@ public void FormatType_WhenTypeIsGeneric_ShouldReturnReadableGenericName() method!.Invoke(null, new object[] { typeof(Dictionary>) })); // Assert - Assert.Equal("Dictionary>>", formatted); + Assert.Equal("Dictionary>", formatted); } [Fact] @@ -145,7 +145,7 @@ public void FormatParameter_WhenParameterIsNullableValueType_ShouldAppendQuestio var formatted = Assert.IsType(method!.Invoke(null, new object[] { parameter })); // Assert - Assert.Equal("Nullable?", formatted); + Assert.Equal("int?", formatted); } [Fact]