Skip to content

Commit f4fb285

Browse files
authored
Merge pull request #5 from mberrishdev/feature/add-options
feat: add document metadata options and improve HubDocs configuration
2 parents c681950 + 98d15e6 commit f4fb285

8 files changed

Lines changed: 497 additions & 5 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,38 @@ If your hubs are in external assemblies, you can specify them:
161161
app.AddHubDocs(typeof(ExternalHub).Assembly);
162162
```
163163

164+
### Document Metadata Options
165+
166+
You can pass metadata (similar to Swagger `info`) that appears in `hubdocs.json` and in the HubDocs UI header:
167+
168+
```csharp
169+
app.AddHubDocs(options =>
170+
{
171+
options.Title = "My SignalR API";
172+
options.Version = "1.0.0";
173+
options.Description = "Realtime messaging API docs.";
174+
options.ProjectUrl = "https://example.com/project";
175+
options.TermsOfService = "https://example.com/terms";
176+
177+
options.Contact.Name = "API Support";
178+
options.Contact.Email = "support@example.com";
179+
options.Contact.Url = "https://example.com/support";
180+
181+
options.License.Name = "MIT";
182+
options.License.Url = "https://example.com/license";
183+
});
184+
```
185+
186+
You can combine options with custom assembly scanning:
187+
188+
```csharp
189+
app.AddHubDocs(options =>
190+
{
191+
options.Title = "External Hubs API";
192+
options.Version = "2.0.0";
193+
}, typeof(ExternalHub).Assembly);
194+
```
195+
164196
### Opt-in Documentation
165197

166198
Only hubs marked with `[HubDocs]` attribute will appear in the documentation UI. This gives you control over which hubs are publicly documented.

samples/HubDocs.Sample/Program.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,21 @@
3030
app.MapHub<NotificationHub>("/hubs/notifications");
3131

3232
// Configure HubDocs - discovers hubs with [HubDocs] attribute from registered endpoints
33-
app.AddHubDocs();
33+
app.AddHubDocs(options =>
34+
{
35+
options.Title = "HubDocs Sample SignalR API";
36+
options.Version = "1.0.0";
37+
options.Description = "Sample project showing HubDocs rich JSON export and interactive SignalR hub explorer.";
38+
options.ProjectUrl = "https://github.com/mberrishdev/HubDocs";
39+
options.TermsOfService = "https://github.com/mberrishdev/HubDocs/blob/main/LICENSE";
40+
41+
options.Contact.Name = "HubDocs Team";
42+
options.Contact.Email = "support@hubdocs.dev";
43+
options.Contact.Url = "https://github.com/mberrishdev/HubDocs/issues";
44+
45+
options.License.Name = "MIT";
46+
options.License.Url = "https://github.com/mberrishdev/HubDocs/blob/main/LICENSE";
47+
});
3448

3549
app.MapControllers();
3650

src/HubDocs/Extensions.cs

Lines changed: 284 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ public static class Extensions
1313

1414
public static WebApplication AddHubDocs(this WebApplication app, params Assembly[] additionalAssemblies)
1515
{
16+
return AddHubDocs(app, _ => { }, additionalAssemblies);
17+
}
18+
19+
public static WebApplication AddHubDocs(this WebApplication app, Action<HubDocsDocumentOptions> configureDocument, params Assembly[] additionalAssemblies)
20+
{
21+
var documentOptions = new HubDocsDocumentOptions();
22+
configureDocument(documentOptions);
23+
1624
app.MapGet("/hubdocs/hubdocs.json", () =>
1725
{
1826
var hubRoutes = GetHubRoutesFromEndpoints(app);
19-
var metadata = DiscoverSignalRHubs(hubRoutes, additionalAssemblies);
20-
return Results.Ok(metadata);
27+
var metadata = DiscoverSignalRHubs(hubRoutes, additionalAssemblies).ToList();
28+
var hubDocsDocument = BuildHubDocsDocument(metadata, documentOptions);
29+
return Results.Ok(hubDocsDocument);
2130
})
2231
.ExcludeFromDescription();
2332

@@ -439,6 +448,279 @@ private static string BuildReturnExample(MethodInfo method)
439448
return CreateExampleLiteral(unwrapped, false);
440449
}
441450

451+
private static Dictionary<string, object?> BuildHubDocsDocument(IReadOnlyList<HubMetadata> hubs)
452+
{
453+
return BuildHubDocsDocument(hubs, new HubDocsDocumentOptions());
454+
}
455+
456+
private static Dictionary<string, object?> BuildHubDocsDocument(IReadOnlyList<HubMetadata> hubs, HubDocsDocumentOptions options)
457+
{
458+
var channels = new Dictionary<string, object?>();
459+
var messages = new Dictionary<string, object?>();
460+
var schemas = new Dictionary<string, object?>();
461+
462+
foreach (var hub in hubs)
463+
{
464+
if (string.IsNullOrWhiteSpace(hub.Path))
465+
continue;
466+
467+
foreach (var schema in hub.Schemas)
468+
{
469+
if (schemas.ContainsKey(schema.Name))
470+
continue;
471+
472+
schemas[schema.Name] = ConvertHubSchemaToProtocolSchema(schema);
473+
}
474+
475+
var publishMessageRefs = new List<object>();
476+
foreach (var method in hub.Methods)
477+
{
478+
var messageName = $"{hub.HubName}.{method.MethodName}.Request";
479+
messages[messageName] = BuildMethodMessage(messageName, method, schemas.Keys);
480+
publishMessageRefs.Add(new Dictionary<string, object?>
481+
{
482+
["$ref"] = $"#/components/messages/{messageName}"
483+
});
484+
}
485+
486+
var subscribeMessageRefs = new List<object>();
487+
foreach (var method in hub.ClientMethods ?? [])
488+
{
489+
var messageName = $"{hub.HubName}.{method.MethodName}.Event";
490+
messages[messageName] = BuildMethodMessage(messageName, method, schemas.Keys);
491+
subscribeMessageRefs.Add(new Dictionary<string, object?>
492+
{
493+
["$ref"] = $"#/components/messages/{messageName}"
494+
});
495+
}
496+
497+
channels[hub.Path] = new Dictionary<string, object?>
498+
{
499+
["publish"] = new Dictionary<string, object?>
500+
{
501+
["operationId"] = $"{hub.HubName}.publish",
502+
["summary"] = $"Client-to-server methods for {hub.HubName}",
503+
["message"] = new Dictionary<string, object?>
504+
{
505+
["oneOf"] = publishMessageRefs
506+
}
507+
},
508+
["subscribe"] = new Dictionary<string, object?>
509+
{
510+
["operationId"] = $"{hub.HubName}.subscribe",
511+
["summary"] = $"Server-to-client methods for {hub.HubName}",
512+
["message"] = new Dictionary<string, object?>
513+
{
514+
["oneOf"] = subscribeMessageRefs
515+
}
516+
}
517+
};
518+
}
519+
520+
return new Dictionary<string, object?>
521+
{
522+
["hubdocs"] = new Dictionary<string, object?>
523+
{
524+
["format"] = "hubdocs-1.0",
525+
["version"] = options.Version,
526+
["title"] = options.Title,
527+
["description"] = options.Description,
528+
["termsOfService"] = options.TermsOfService,
529+
["projectUrl"] = options.ProjectUrl,
530+
["contact"] = new Dictionary<string, object?>
531+
{
532+
["name"] = options.Contact.Name,
533+
["email"] = options.Contact.Email,
534+
["url"] = options.Contact.Url
535+
},
536+
["license"] = new Dictionary<string, object?>
537+
{
538+
["name"] = options.License.Name,
539+
["url"] = options.License.Url
540+
},
541+
["generatedAtUtc"] = DateTime.UtcNow.ToString("O")
542+
},
543+
["hubs"] = hubs,
544+
["channels"] = channels,
545+
["components"] = new Dictionary<string, object?>
546+
{
547+
["messages"] = messages,
548+
["schemas"] = schemas
549+
}
550+
};
551+
}
552+
553+
private static Dictionary<string, object?> BuildMethodMessage(string messageName, HubMethodMetadata method, IEnumerable<string> knownSchemas)
554+
{
555+
var argumentProperties = new Dictionary<string, object?>();
556+
557+
foreach (var parameter in method.Parameters)
558+
{
559+
argumentProperties[parameter.Name] = BuildJsonSchemaForType(parameter.Type, knownSchemas, parameter.Example, parameter.IsNullable);
560+
}
561+
562+
var payloadProperties = new Dictionary<string, object?>
563+
{
564+
["method"] = new Dictionary<string, object?>
565+
{
566+
["type"] = "string",
567+
["example"] = method.MethodName
568+
},
569+
["arguments"] = new Dictionary<string, object?>
570+
{
571+
["type"] = "object",
572+
["properties"] = argumentProperties
573+
},
574+
["returns"] = BuildJsonSchemaForType(method.ReturnType, knownSchemas, method.ReturnExample, false)
575+
};
576+
577+
return new Dictionary<string, object?>
578+
{
579+
["name"] = messageName,
580+
["title"] = method.Signature,
581+
["payload"] = new Dictionary<string, object?>
582+
{
583+
["type"] = "object",
584+
["properties"] = payloadProperties,
585+
["required"] = new[] { "method", "arguments" }
586+
},
587+
["examples"] = new[]
588+
{
589+
new Dictionary<string, object?>
590+
{
591+
["name"] = $"{method.MethodName}Example",
592+
["summary"] = method.Signature,
593+
["payload"] = new Dictionary<string, object?>
594+
{
595+
["method"] = method.MethodName,
596+
["arguments"] = method.Parameters.ToDictionary(p => p.Name, p => (object?)p.Example),
597+
["returns"] = method.ReturnExample
598+
}
599+
}
600+
}
601+
};
602+
}
603+
604+
private static Dictionary<string, object?> BuildJsonSchemaForType(string csharpType, IEnumerable<string> knownSchemas, string? example, bool nullable)
605+
{
606+
var cleaned = csharpType.Trim();
607+
if (cleaned.EndsWith("?", StringComparison.Ordinal))
608+
{
609+
cleaned = cleaned[..^1];
610+
nullable = true;
611+
}
612+
613+
if (cleaned.EndsWith("[]", StringComparison.Ordinal))
614+
{
615+
var itemType = cleaned[..^2];
616+
var itemSchema = BuildJsonSchemaForType(itemType, knownSchemas, null, false);
617+
var arraySchema = new Dictionary<string, object?>
618+
{
619+
["type"] = "array",
620+
["items"] = itemSchema
621+
};
622+
623+
if (example != null)
624+
arraySchema["example"] = example;
625+
626+
if (nullable)
627+
arraySchema["nullable"] = true;
628+
629+
return arraySchema;
630+
}
631+
632+
if (cleaned.StartsWith("List<", StringComparison.Ordinal) && cleaned.EndsWith(">", StringComparison.Ordinal))
633+
{
634+
var inner = cleaned[5..^1];
635+
var itemSchema = BuildJsonSchemaForType(inner, knownSchemas, null, false);
636+
var listSchema = new Dictionary<string, object?>
637+
{
638+
["type"] = "array",
639+
["items"] = itemSchema
640+
};
641+
642+
if (example != null)
643+
listSchema["example"] = example;
644+
645+
if (nullable)
646+
listSchema["nullable"] = true;
647+
648+
return listSchema;
649+
}
650+
651+
var schemaName = cleaned.Contains('<')
652+
? cleaned[..cleaned.IndexOf('<')]
653+
: cleaned;
654+
655+
if (knownSchemas.Contains(schemaName, StringComparer.Ordinal))
656+
{
657+
var refSchema = new Dictionary<string, object?>
658+
{
659+
["$ref"] = $"#/components/schemas/{schemaName}"
660+
};
661+
662+
if (nullable)
663+
refSchema["nullable"] = true;
664+
665+
return refSchema;
666+
}
667+
668+
var primitive = MapPrimitiveJsonSchema(cleaned);
669+
if (example != null)
670+
primitive["example"] = example;
671+
if (nullable)
672+
primitive["nullable"] = true;
673+
return primitive;
674+
}
675+
676+
private static Dictionary<string, object?> MapPrimitiveJsonSchema(string csharpType)
677+
{
678+
return csharpType switch
679+
{
680+
"bool" => new Dictionary<string, object?> { ["type"] = "boolean" },
681+
"byte" or "sbyte" or "short" or "ushort" or "int" or "uint" or "long" or "ulong" =>
682+
new Dictionary<string, object?> { ["type"] = "integer", ["format"] = "int64" },
683+
"float" => new Dictionary<string, object?> { ["type"] = "number", ["format"] = "float" },
684+
"double" or "decimal" => new Dictionary<string, object?> { ["type"] = "number", ["format"] = "double" },
685+
"Guid" => new Dictionary<string, object?> { ["type"] = "string", ["format"] = "uuid" },
686+
"DateTime" or "DateTimeOffset" => new Dictionary<string, object?> { ["type"] = "string", ["format"] = "date-time" },
687+
"TimeSpan" => new Dictionary<string, object?> { ["type"] = "string" },
688+
"Task" or "void" => new Dictionary<string, object?> { ["type"] = "null" },
689+
_ => new Dictionary<string, object?> { ["type"] = "string" }
690+
};
691+
}
692+
693+
private static Dictionary<string, object?> ConvertHubSchemaToProtocolSchema(HubTypeSchemaMetadata schema)
694+
{
695+
if (schema.Kind == "enum")
696+
{
697+
var enumNames = (schema.EnumValues ?? [])
698+
.Select(v => v.Split('=')[0].Trim())
699+
.Where(v => !string.IsNullOrWhiteSpace(v))
700+
.ToList();
701+
702+
return new Dictionary<string, object?>
703+
{
704+
["type"] = "string",
705+
["enum"] = enumNames,
706+
["example"] = enumNames.FirstOrDefault()
707+
};
708+
}
709+
710+
var properties = new Dictionary<string, object?>();
711+
foreach (var property in schema.Properties ?? [])
712+
{
713+
properties[property.Name] = BuildJsonSchemaForType(property.Type, [], property.Example, property.IsNullable);
714+
}
715+
716+
return new Dictionary<string, object?>
717+
{
718+
["type"] = "object",
719+
["properties"] = properties,
720+
["example"] = schema.Example
721+
};
722+
}
723+
442724
private static string CreateExampleLiteral(Type type, bool nullable)
443725
{
444726
if (nullable)

src/HubDocs/HubDocs.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
88
<PackageId>HubDocs</PackageId>
9-
<Version>0.0.8</Version>
9+
<Version>0.0.9</Version>
1010
<Company>BerrishDev</Company>
1111
<Product>HubDocs</Product>
1212
<Description>Swagger-like documentation UI for SignalR Hubs</Description>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace HubDocs;
2+
3+
public class HubDocsDocumentOptions
4+
{
5+
public string Title { get; set; } = "HubDocs SignalR Protocol";
6+
public string Version { get; set; } = "1.0.0";
7+
public string? Description { get; set; } = "HubDocs protocol export with channels, messages, and schemas.";
8+
public string? TermsOfService { get; set; }
9+
public string? ProjectUrl { get; set; }
10+
public HubDocsContactOptions Contact { get; set; } = new();
11+
public HubDocsLicenseOptions License { get; set; } = new();
12+
}
13+
14+
public class HubDocsContactOptions
15+
{
16+
public string? Name { get; set; }
17+
public string? Email { get; set; }
18+
public string? Url { get; set; }
19+
}
20+
21+
public class HubDocsLicenseOptions
22+
{
23+
public string? Name { get; set; }
24+
public string? Url { get; set; }
25+
}

0 commit comments

Comments
 (0)