Skip to content

Commit

Permalink
Add support for listing available OpenAPI documents (#3263)
Browse files Browse the repository at this point in the history
- Add `ISwaggerDocumentMetadataProvider` to get the names of the available OpenAPI documents.
- Add new `list` command to the CLI that lists the available document names.
  • Loading branch information
rassilon authored Feb 26, 2025
1 parent 49806fe commit b84ea3b
Show file tree
Hide file tree
Showing 15 changed files with 226 additions and 47 deletions.
138 changes: 105 additions & 33 deletions src/Swashbuckle.AspNetCore.Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -41,29 +43,7 @@ public static int Main(string[] args)

c.OnRun((namedArgs) =>
{
if (!File.Exists(namedArgs["startupassembly"]))
{
throw new FileNotFoundException(namedArgs["startupassembly"]);
}

var depsFile = namedArgs["startupassembly"].Replace(".dll", ".deps.json");
var runtimeConfig = namedArgs["startupassembly"].Replace(".dll", ".runtimeconfig.json");
var commandName = args[0];

var subProcessArguments = new string[args.Length - 1];
if (subProcessArguments.Length > 0)
{
Array.Copy(args, 1, subProcessArguments, 0, subProcessArguments.Length);
}

var subProcessCommandLine = string.Format(
"exec --depsfile {0} --runtimeconfig {1} {2} _{3} {4}", // note the underscore prepended to the command name
EscapePath(depsFile),
EscapePath(runtimeConfig),
EscapePath(typeof(Program).GetTypeInfo().Assembly.Location),
commandName,
string.Join(" ", subProcessArguments.Select(x => EscapePath(x)))
);
string subProcessCommandLine = PrepareCommandLine(args, namedArgs);

var subProcess = Process.Start("dotnet", subProcessCommandLine);

Expand All @@ -84,16 +64,7 @@ public static int Main(string[] args)
c.Option("--yaml", "", true);
c.OnRun((namedArgs) =>
{
// 1) Configure host with provided startupassembly
var startupAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(
Path.Combine(Directory.GetCurrentDirectory(), namedArgs["startupassembly"]));

// 2) Build a service container that's based on the startup assembly
var serviceProvider = GetServiceProvider(startupAssembly);

// 3) Retrieve Swagger via configured provider
var swaggerProvider = serviceProvider.GetRequiredService<ISwaggerProvider>();
var swaggerOptions = serviceProvider.GetService<IOptions<SwaggerOptions>>();
SetupAndRetrieveSwaggerProviderAndOptions(namedArgs, out var swaggerProvider, out var swaggerOptions);
var swaggerDocumentSerializer = swaggerOptions?.Value?.CustomDocumentSerializer;
var swagger = swaggerProvider.GetSwagger(
namedArgs["swaggerdoc"],
Expand Down Expand Up @@ -156,9 +127,110 @@ public static int Main(string[] args)
});
});

// > dotnet swagger list
runner.SubCommand("list", "retrieves the list of Swagger document names from a startup assembly", c =>
{
c.Argument("startupassembly", "relative path to the application's startup assembly");
c.Option("--output", "relative path where the document names will be output, defaults to stdout");
c.OnRun((namedArgs) =>
{
string subProcessCommandLine = PrepareCommandLine(args, namedArgs);

var subProcess = Process.Start("dotnet", subProcessCommandLine);

subProcess.WaitForExit();
return subProcess.ExitCode;
});
});

// > dotnet swagger _list ... (* should only be invoked via "dotnet exec")
runner.SubCommand("_list", "", c =>
{
c.Argument("startupassembly", "");
c.Option("--output", "");
c.OnRun((namedArgs) =>
{
SetupAndRetrieveSwaggerProviderAndOptions(namedArgs, out var swaggerProvider, out var swaggerOptions);
IList<string> docNames = new List<string>();

string outputPath = namedArgs.TryGetValue("--output", out var arg1)
? Path.Combine(Directory.GetCurrentDirectory(), arg1)
: null;
bool outputViaConsole = outputPath == null;
if (!string.IsNullOrEmpty(outputPath))
{
string directoryPath = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
}

using Stream stream = outputViaConsole ? Console.OpenStandardOutput() : File.Create(outputPath);
using StreamWriter writer = new(stream, outputViaConsole ? Console.OutputEncoding : Encoding.UTF8);

if (swaggerProvider is not ISwaggerDocumentMetadataProvider docMetaProvider)
{
writer.WriteLine($"The registered {nameof(ISwaggerProvider)} instance does not implement {nameof(ISwaggerDocumentMetadataProvider)}; unable to list the Swagger document names.");
return -1;
}

docNames = docMetaProvider.GetDocumentNames();

foreach (var name in docNames)
{
writer.WriteLine($"\"{name}\"");
}

return 0;
});
});

return runner.Run(args);
}

private static void SetupAndRetrieveSwaggerProviderAndOptions(System.Collections.Generic.IDictionary<string, string> namedArgs, out ISwaggerProvider swaggerProvider, out IOptions<SwaggerOptions> swaggerOptions)
{
// 1) Configure host with provided startupassembly
var startupAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(
Path.Combine(Directory.GetCurrentDirectory(), namedArgs["startupassembly"]));

// 2) Build a service container that's based on the startup assembly
var serviceProvider = GetServiceProvider(startupAssembly);

// 3) Retrieve Swagger via configured provider
swaggerProvider = serviceProvider.GetRequiredService<ISwaggerProvider>();
swaggerOptions = serviceProvider.GetService<IOptions<SwaggerOptions>>();
}

private static string PrepareCommandLine(string[] args, System.Collections.Generic.IDictionary<string, string> namedArgs)
{
if (!File.Exists(namedArgs["startupassembly"]))
{
throw new FileNotFoundException(namedArgs["startupassembly"]);
}

var depsFile = namedArgs["startupassembly"].Replace(".dll", ".deps.json");
var runtimeConfig = namedArgs["startupassembly"].Replace(".dll", ".runtimeconfig.json");
var commandName = args[0];

var subProcessArguments = new string[args.Length - 1];
if (subProcessArguments.Length > 0)
{
Array.Copy(args, 1, subProcessArguments, 0, subProcessArguments.Length);
}

var subProcessCommandLine = string.Format(
"exec --depsfile {0} --runtimeconfig {1} {2} _{3} {4}", // note the underscore prepended to the command name
EscapePath(depsFile),
EscapePath(runtimeConfig),
EscapePath(typeof(Program).GetTypeInfo().Assembly.Location),
commandName,
string.Join(" ", subProcessArguments.Select(x => EscapePath(x)))
);
return subProcessCommandLine;
}

private static string EscapePath(string path)
{
return path.Contains(' ')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;

namespace Swashbuckle.AspNetCore.Swagger
{
public interface ISwaggerDocumentMetadataProvider
{
IList<string> GetDocumentNames();
}
}
2 changes: 1 addition & 1 deletion src/Swashbuckle.AspNetCore.Swagger/ISwaggerProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ public UnknownSwaggerDocument(string documentName, IEnumerable<string> knownDocu
string.Join(",", knownDocuments?.Select(x => $"\"{x}\""))))
{}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Swashbuckle.AspNetCore.Swagger.ISwaggerDocumentMetadataProvider
Swashbuckle.AspNetCore.Swagger.ISwaggerDocumentMetadataProvider.GetDocumentNames() -> System.Collections.Generic.IList<string>
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
static Swashbuckle.AspNetCore.SwaggerGen.OpenApiAnyFactory.CreateFromJson(string json, System.Text.Json.JsonSerializerOptions options) -> Microsoft.OpenApi.Any.IOpenApiAny
static Swashbuckle.AspNetCore.SwaggerGen.XmlCommentsTextHelper.Humanize(string text, string xmlCommentEndOfLine) -> string
Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GetDocumentNames() -> System.Collections.Generic.IList<string>
Swashbuckle.AspNetCore.SwaggerGen.SwaggerGeneratorOptions.XmlCommentEndOfLine.get -> string
Swashbuckle.AspNetCore.SwaggerGen.SwaggerGeneratorOptions.XmlCommentEndOfLine.set -> void
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

namespace Swashbuckle.AspNetCore.SwaggerGen
{
public class SwaggerGenerator : ISwaggerProvider, IAsyncSwaggerProvider
public class SwaggerGenerator : ISwaggerProvider, IAsyncSwaggerProvider, ISwaggerDocumentMetadataProvider
{
private readonly IApiDescriptionGroupCollectionProvider _apiDescriptionsProvider;
private readonly ISchemaGenerator _schemaGenerator;
Expand Down Expand Up @@ -112,6 +112,8 @@ public OpenApiDocument GetSwagger(string documentName, string host = null, strin
}
}

public IList<string> GetDocumentNames() => _options.SwaggerDocs.Keys.ToList();

private void SortSchemas(OpenApiDocument document)
{
document.Components.Schemas = new SortedDictionary<string, OpenApiSchema>(document.Components.Schemas, _options.SchemaComparer);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
static Microsoft.AspNetCore.Builder.SwaggerUIOptionsExtensions.EnableSwaggerDocumentUrlsEndpoint(this Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions options) -> void
Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions.ExposeSwaggerDocumentUrlsRoute.get -> bool
Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions.ExposeSwaggerDocumentUrlsRoute.set -> void
Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions.SwaggerDocumentUrlsPath.get -> string
Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions.SwaggerDocumentUrlsPath.set -> void
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#if NET6_0_OR_GREATER
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
Expand All @@ -8,6 +9,7 @@ namespace Swashbuckle.AspNetCore.SwaggerUI;

[JsonSerializable(typeof(ConfigObject))]
[JsonSerializable(typeof(InterceptorFunctions))]
[JsonSerializable(typeof(List<UrlDescriptor>))]
[JsonSerializable(typeof(OAuthConfigObject))]
// These primitive types are declared for common types that may be used with ConfigObject.AdditionalItems. See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/2884.
[JsonSerializable(typeof(bool))]
Expand Down
41 changes: 40 additions & 1 deletion src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -88,6 +90,13 @@ public async Task Invoke(HttpContext httpContext)
await RespondWithFile(httpContext.Response, match.Groups[1].Value);
return;
}

var pattern = $"^/?{Regex.Escape(_options.RoutePrefix)}/{_options.SwaggerDocumentUrlsPath}/?$";
if (Regex.IsMatch(path, pattern, RegexOptions.IgnoreCase))
{
await RespondWithDocumentUrls(httpContext.Response);
return;
}
}

await _staticFileMiddleware.Invoke(httpContext);
Expand Down Expand Up @@ -150,6 +159,36 @@ private async Task RespondWithFile(HttpResponse response, string fileName)
}
}

#if NET5_0_OR_GREATER
[UnconditionalSuppressMessage(
"AOT",
"IL2026:RequiresUnreferencedCode",
Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")]
[UnconditionalSuppressMessage(
"AOT",
"IL3050:RequiresDynamicCode",
Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")]
#endif
private async Task RespondWithDocumentUrls(HttpResponse response)
{
response.StatusCode = 200;

response.ContentType = "application/javascript;charset=utf-8";
string json = "[]";

#if NET6_0_OR_GREATER
if (_jsonSerializerOptions is null)
{
var l = new List<UrlDescriptor>(_options.ConfigObject.Urls);
json = JsonSerializer.Serialize(l, SwaggerUIOptionsJsonContext.Default.ListUrlDescriptor);
}
#endif

json ??= JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions);

await response.WriteAsync(json, Encoding.UTF8);
}

#if NET5_0_OR_GREATER
[UnconditionalSuppressMessage(
"AOT",
Expand Down
12 changes: 12 additions & 0 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ public class SwaggerUIOptions
/// Gets or sets the path or URL to the Swagger UI CSS file.
/// </summary>
public string StylesPath { get; set; } = "./swagger-ui.css";

/// <summary>
/// Gets or sets whether to expose the <c><see cref="SwaggerUIOptions.ConfigObject">ConfigObject</see>.Urls</c> object via an
/// HTTP endpoint with the URL specified by <see cref="SwaggerDocumentUrlsPath"/>
/// so that external code can auto-discover all Swagger documents.
/// </summary>
public bool ExposeSwaggerDocumentUrlsRoute { get; set; } = false;

/// <summary>
/// Gets or sets the relative URL path to the route that exposes the values of the configured <see cref="ConfigObject.Urls"/> values.
/// </summary>
public string SwaggerDocumentUrlsPath { get; set; } = "documentUrls";
}

public class ConfigObject
Expand Down
14 changes: 12 additions & 2 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ public static void ValidatorUrl(this SwaggerUIOptions options, string url)

/// <summary>
/// You can use this parameter to enable the swagger-ui's built-in validator (badge) functionality
/// Setting it to null will disable validation
/// Setting it to null will disable validation
/// </summary>
/// <param name="options"></param>
/// <param name="url"></param>
Expand Down Expand Up @@ -239,7 +239,7 @@ public static void OAuthUsername(this SwaggerUIOptions options, string value)
/// <param name="value"></param>
/// <remarks>Setting this exposes the client secrets in inline javascript in the swagger-ui generated html.</remarks>
public static void OAuthClientSecret(this SwaggerUIOptions options, string value)
{
{
options.OAuthConfigObject.ClientSecret = value;
}

Expand Down Expand Up @@ -333,5 +333,15 @@ public static void UseResponseInterceptor(this SwaggerUIOptions options, string
{
options.Interceptors.ResponseInterceptorFunction = value;
}

/// <summary>
/// Function to enable the <see cref="SwaggerUIOptions.ExposeSwaggerDocumentUrlsRoute"/> option to expose the available
/// Swagger document urls to external parties.
/// </summary>
/// <param name="options"></param>
public static void EnableSwaggerDocumentUrlsEndpoint(this SwaggerUIOptions options)
{
options.ExposeSwaggerDocumentUrlsRoute = true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ProjectReference Include="..\WebSites\MinimalAppWithHostedServices\MinimalAppWithHostedServices.csproj" />
<ProjectReference Include="..\WebSites\MinimalApp\MinimalApp.csproj" />
<ProjectReference Include="..\..\src\Swashbuckle.AspNetCore.Cli\Swashbuckle.AspNetCore.Cli.csproj" />
<ProjectReference Include="..\WebSites\MultipleVersions\MultipleVersions.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading

0 comments on commit b84ea3b

Please sign in to comment.