diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 1706cc8..1a6e07c 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -16,6 +16,7 @@ jobs: - { name: 'Tingle.AspNetCore.Authorization' } - { name: 'Tingle.AspNetCore.DataProtection.MongoDB' } - { name: 'Tingle.AspNetCore.JsonPatch.NewtonsoftJson' } + - { name: 'Tingle.AspNetCore.Swagger' } - { name: 'Tingle.AspNetCore.Tokens' } - { name: 'Tingle.Extensions.Caching.MongoDB' } - { name: 'Tingle.Extensions.DataAnnotations' } diff --git a/README.md b/README.md index 0c72957..2995cea 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository contains projects/libraries for adding useful functionality to . |[`Tingle.AspNetCore.Authorization`](https://www.nuget.org/packages/Tingle.AspNetCore.Authorization/)|Additional authorization functionality such as handlers and requirements. See [docs](./src/Tingle.AspNetCore.Authorization/README.md) and [sample](./samples/AuthorizationSample)| |[`Tingle.AspNetCore.DataProtection.MongoDB`](https://www.nuget.org/packages/Tingle.AspNetCore.DataProtection.MongoDB/)|Data Protection store in [MongoDB](https://mongodb.com) for ASP.NET Core. See [docs](./src/Tingle.AspNetCore.DataProtection.MongoDB/README.md) and [sample](./samples/DataProtectionMongoDBSample).| |[`Tingle.AspNetCore.JsonPatch.NewtonsoftJson`](https://www.nuget.org/packages/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/)|Helpers for validation when working with JsonPatch in ASP.NET Core. See [docs](./src/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/README.md) and [blog](https://maxwellweru.com/blog/2020-11-17-immutable-properties-with-json-patch-in-aspnet-core).| +|[`Tingle.AspNetCore.Swagger`](https://www.nuget.org/packages/Tingle.AspNetCore.Swagger/)|Usability extensions for Swagger middleware including smaller ReDoc support. See [docs](./src/Tingle.AspNetCore.Swagger/README.md).| |[`Tingle.AspNetCore.Tokens`](https://www.nuget.org/packages/Tingle.AspNetCore.Tokens/)|Support for generation of continuation tokens in ASP.NET Core with optional expiry. Useful for pagination, user invite tokens, expiring operation tokens, etc. This is availed through the `ContinuationToken` and `TimedContinuationToken` types. See [docs](./src/Tingle.AspNetCore.Tokens/README.md) and [sample](./samples/TokensSample).| |[`Tingle.Extensions.Caching.MongoDB`](https://www.nuget.org/packages/Tingle.Extensions.Caching.MongoDB/)|Distributed caching implemented with [MongoDB](https://mongodb.com) on top of `IDistributedCache`, inspired by [CosmosCache](https://github.com/Azure/Microsoft.Extensions.Caching.Cosmos). See [docs](./src/Tingle.Extensions.Caching.MongoDB/README.md) and [sample](./samples/AspNetCoreSessionState)| |[`Tingle.Extensions.DataAnnotations`](https://www.nuget.org/packages/Tingle.Extensions.DataAnnotations/)|Additional data validation attributes in the `System.ComponentModel.DataAnnotations` namespace. Some of this should have been present in the framework but are very specific to some use cases. For example `FiveStarRatingAttribute`. See [docs](./src/Tingle.Extensions.DataAnnotations/README.md).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index 4c43ec1..d5eb311 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.DataProte EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.JsonPatch.NewtonsoftJson", "src\Tingle.AspNetCore.JsonPatch.NewtonsoftJson\Tingle.AspNetCore.JsonPatch.NewtonsoftJson.csproj", "{82B17C91-B96A-4290-A623-6867912A4C8E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Swagger", "src\Tingle.AspNetCore.Swagger\Tingle.AspNetCore.Swagger.csproj", "{C8093B92-5322-4B24-B71B-497340E5C5AA}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Tokens", "src\Tingle.AspNetCore.Tokens\Tingle.AspNetCore.Tokens.csproj", "{B545B88C-4BE0-43FB-AE87-47706D479C6B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Caching.MongoDB", "src\Tingle.Extensions.Caching.MongoDB\Tingle.Extensions.Caching.MongoDB.csproj", "{0C6BE46B-FFBF-497C-82D9-6148364D24E8}" @@ -55,6 +57,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.DataProte EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.JsonPatch.NewtonsoftJson.Tests", "tests\Tingle.AspNetCore.JsonPatch.NewtonsoftJson.Tests\Tingle.AspNetCore.JsonPatch.NewtonsoftJson.Tests.csproj", "{55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Swagger.Tests", "tests\Tingle.AspNetCore.Swagger.Tests\Tingle.AspNetCore.Swagger.Tests.csproj", "{AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Tokens.Tests", "tests\Tingle.AspNetCore.Tokens.Tests\Tingle.AspNetCore.Tokens.Tests.csproj", "{FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Caching.MongoDB.Tests", "tests\Tingle.Extensions.Caching.MongoDB.Tests\Tingle.Extensions.Caching.MongoDB.Tests.csproj", "{0EA063C6-9A97-4DE8-9344-5D2BDD301134}" @@ -128,6 +132,10 @@ Global {82B17C91-B96A-4290-A623-6867912A4C8E}.Debug|Any CPU.Build.0 = Debug|Any CPU {82B17C91-B96A-4290-A623-6867912A4C8E}.Release|Any CPU.ActiveCfg = Release|Any CPU {82B17C91-B96A-4290-A623-6867912A4C8E}.Release|Any CPU.Build.0 = Release|Any CPU + {C8093B92-5322-4B24-B71B-497340E5C5AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8093B92-5322-4B24-B71B-497340E5C5AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8093B92-5322-4B24-B71B-497340E5C5AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8093B92-5322-4B24-B71B-497340E5C5AA}.Release|Any CPU.Build.0 = Release|Any CPU {B545B88C-4BE0-43FB-AE87-47706D479C6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B545B88C-4BE0-43FB-AE87-47706D479C6B}.Debug|Any CPU.Build.0 = Debug|Any CPU {B545B88C-4BE0-43FB-AE87-47706D479C6B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -196,6 +204,10 @@ Global {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}.Release|Any CPU.Build.0 = Release|Any CPU + {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Release|Any CPU.Build.0 = Release|Any CPU {FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -293,6 +305,7 @@ Global {22690754-DCFB-4CD2-968D-239C1952B52C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {82B17C91-B96A-4290-A623-6867912A4C8E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {C8093B92-5322-4B24-B71B-497340E5C5AA} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {B545B88C-4BE0-43FB-AE87-47706D479C6B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {0C6BE46B-FFBF-497C-82D9-6148364D24E8} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {51FA6572-8EB6-4291-8D02-BB736057A50E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} @@ -310,6 +323,7 @@ Global {E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C} {E28F4E8D-148B-4583-A27D-E1DA2CC08167} = {815F0941-3B70-4705-A583-AF627559595C} {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C} = {815F0941-3B70-4705-A583-AF627559595C} + {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED} = {815F0941-3B70-4705-A583-AF627559595C} {FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684} = {815F0941-3B70-4705-A583-AF627559595C} {0EA063C6-9A97-4DE8-9344-5D2BDD301134} = {815F0941-3B70-4705-A583-AF627559595C} {8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD} = {815F0941-3B70-4705-A583-AF627559595C} diff --git a/src/Tingle.AspNetCore.Swagger/Extensions/IServiceCollectionExtensions.cs b/src/Tingle.AspNetCore.Swagger/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..2eca947 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,92 @@ +#if NET8_0_OR_GREATER +using Asp.Versioning.ApiExplorer; +#endif +using Microsoft.Extensions.Options; +#if NET8_0_OR_GREATER +using Microsoft.OpenApi.Models; +#endif +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for +/// +public static class IServiceCollectionExtensions +{ + /// + /// Adds conversion of XML comments extracted for Swagger to markdown. + /// + /// the service collection to use + /// + public static IServiceCollection AddSwaggerXmlToMarkdown(this IServiceCollection services) + { + return services.AddTransient, ConfigureSwaggerGenXmlToMarkdown>(); + } + + /// + /// Adds enum descriptions. + /// This should be called after all XML documents have been added. + /// + /// the service collection to use + /// + public static IServiceCollection AddSwaggerEnumDescriptions(this IServiceCollection services) + { + return services.AddTransient, ConfigureSwaggerGenEnumDescriptions>(); + } + +#if NET8_0_OR_GREATER + + /// + /// Adds swagger documents for api version descriptions declared in code. + /// This is resolved through + /// + /// the service collection to use + /// the title of the documents + /// the description of the documents, if any + /// the suffix to add for deprecated versions + /// set true to skip versions marked as deprecated + /// an action to customize the created instance of before adding it + /// + public static IServiceCollection AddSwaggerDocsAutoDiscovery(this IServiceCollection services, + string? title = null, + string? description = null, + bool skipDeprecated = false, + string deprecationSuffix = "[deprecated]", + Action? customize = null) + { + return services.AddTransient>(serviceProvider => + { + void configureAllVersions(IEnumerable versions, SwaggerGenOptions options) + { + options.AddDocuments(versions: versions, + title: title, + description: description, + skipDeprecated: skipDeprecated, + deprecationSuffix: deprecationSuffix, + customize: customize); + } + var provider = serviceProvider.GetRequiredService(); + return new ConfigureSwaggerGenAddDocs(provider, configureAllVersions); + }); + } + + /// + /// Configures the Swagger generation options. + /// + /// + /// This allows API versioning to define a Swagger document per API version after the + /// service has been resolved from the service container. + /// + /// the provider used to generate Swagger documents. + /// the action to call when configuring + internal class ConfigureSwaggerGenAddDocs(IApiVersionDescriptionProvider provider, Action, SwaggerGenOptions> configure) + : IConfigureOptions + { + /// + public void Configure(SwaggerGenOptions options) => configure?.Invoke(provider.ApiVersionDescriptions, options); + } + +#endif +} diff --git a/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.ReDoc.cs b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.ReDoc.cs new file mode 100644 index 0000000..e210621 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.ReDoc.cs @@ -0,0 +1,24 @@ +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger; +using Tingle.AspNetCore.Swagger.Filters.Documents; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for +/// +public static partial class SwaggerGenExtensions +{ + /// + /// Add logo for ReDoc to the document using the vendor extension x-logo + /// + /// + /// The logo details + /// + /// + public static SwaggerGenOptions AddReDocLogo(this SwaggerGenOptions options, OpenApiReDocLogo logo) + { + options.DocumentFilter(logo); + return options; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.Versioning.cs b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.Versioning.cs new file mode 100644 index 0000000..82f674d --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.Versioning.cs @@ -0,0 +1,112 @@ +#if NET8_0_OR_GREATER +using Asp.Versioning.ApiExplorer; +#endif +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for +/// +public static partial class SwaggerGenExtensions +{ + /// + /// Adds a swagger document + /// + /// + /// the name of the document e.g. v1 + /// the name of the version e.g. v1-2019 + /// the title of the document + /// the description of the document, if any + /// an action to customize the created instance of before adding it + /// + public static SwaggerGenOptions AddDocument(this SwaggerGenOptions options, + string documentName, + string? versionName = null, + string? title = null, + string? description = null, + Action? customize = null) + { + var info = new OpenApiInfo + { + Version = versionName ?? documentName, + Title = title, + Description = description, + }; + customize?.Invoke(info); + options.SwaggerDoc(documentName, info); + return options; + } + +#if NET8_0_OR_GREATER + + /// + /// Adds a swagger document from an API version description + /// + /// + /// the API version description + /// the title of the document + /// the description of the document, if any + /// the suffix to add for deprecated versions + /// an action to customize the created instance of before adding it + /// + public static SwaggerGenOptions AddDocument(this SwaggerGenOptions options, + ApiVersionDescription version, + string? title = null, + string? description = null, + string? deprecationSuffix = "[deprecated]", + Action? customize = null) + { + var finalTitle = title; + if (version.IsDeprecated) finalTitle += $" {deprecationSuffix}"; + var info = new OpenApiInfo + { + Version = version.ApiVersion.ToString(), + Title = finalTitle, + Description = description, + }; + customize?.Invoke(info); + options.SwaggerDoc(version.GroupName, info); + return options; + } + + /// + /// Adds swagger documents for each API version description provided + /// + /// + /// the versions for which to add swagger documents + /// the title of the documents + /// the description of the documents, if any + /// the suffix to add for deprecated versions + /// set true to skip versions marked as deprecated + /// an action to customize the created instance of before adding it + /// + public static SwaggerGenOptions AddDocuments(this SwaggerGenOptions options, + IEnumerable versions, + string? title = null, + string? description = null, + bool skipDeprecated = false, + string deprecationSuffix = "[deprecated]", + Action? customize = null) + { + // add a swagger document for each discovered API version + foreach (var v in versions) + { + // skip deprecated versions if specified + if (skipDeprecated && v.IsDeprecated) continue; + + // add document + options.AddDocument( + version: v, + title: title, + description: description, + deprecationSuffix: deprecationSuffix, + customize: customize); + } + + return options; + } +#endif + +} diff --git a/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.XmlComments.cs b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.XmlComments.cs new file mode 100644 index 0000000..3be4428 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.XmlComments.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Hosting; +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters.Schemas; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for +/// +public static partial class SwaggerGenExtensions +{ + /// + /// Adds XML comments from a file in a certain directory. + /// + /// + /// the directory where the files exists + /// the name of the file including the extension + /// + /// Flag to indicate if controller XML comments (i.e. summary) should be used to + /// assign Tag descriptions. Don't set this flag if you're customizing the default + /// tag for operations via TagActionsBy. + /// + /// Whether to check if the file exists prior to adding. + /// + public static SwaggerGenOptions IncludeXmlComments(this SwaggerGenOptions options, + string directory, + string fileName, + bool includeControllerXmlComments = false, + bool checkExists = false) + { + var filePath = Path.Combine(directory, fileName); + if (checkExists && !File.Exists(filePath)) return options; + + options.IncludeXmlComments(filePath: filePath, + includeControllerXmlComments: includeControllerXmlComments); + return options; + } + + /// + /// Adds XML comments from a file in a certain directory + /// + /// + /// The in which the application is running. + /// the name of the file including the extension + /// + /// Flag to indicate if controller XML comments (i.e. summary) should be used to + /// assign Tag descriptions. Don't set this flag if you're customizing the default + /// tag for operations via TagActionsBy. + /// + /// Whether to check if the file exists prior to adding. + /// + public static SwaggerGenOptions IncludeXmlComments(this SwaggerGenOptions options, + IHostEnvironment environment, + string fileName, + bool includeControllerXmlComments = false, + bool checkExists = false) + { + var filePath = Path.Combine(environment.ContentRootPath, fileName); + + if (File.Exists(filePath)) + { + options.IncludeXmlComments(filePath: filePath, includeControllerXmlComments: includeControllerXmlComments); + return options; + } + + // During debug, the file will be not be present in ContentRootPath so we use the AppDomain instead + return options.IncludeXmlComments(directory: AppDomain.CurrentDomain.BaseDirectory, + fileName: fileName, + includeControllerXmlComments: includeControllerXmlComments, + checkExists: checkExists); + } + + /// + /// Adds XML comments from the assembly of the specified type + /// + /// the type where to get the assembly + /// + /// The in which the application is running. + /// + /// Flag to indicate if controller XML comments (i.e. summary) should be used to + /// assign Tag descriptions. Don't set this flag if you're customizing the default + /// tag for operations via TagActionsBy. + /// + /// Whether to check if the file exists prior to adding. + /// + public static SwaggerGenOptions IncludeXmlComments(this SwaggerGenOptions options, + IHostEnvironment environment, + bool includeControllerXmlComments = false, + bool checkExists = false) + { + var fileName = $"{typeof(T).Assembly.GetName().Name}.xml"; + return options.IncludeXmlComments(environment: environment, + fileName: fileName, + includeControllerXmlComments: includeControllerXmlComments, + checkExists: checkExists); + } + + /// + /// Add descriptions to schemas based on <inheritdoc/> XML summary comments. + /// + /// + /// Types for which inheritance will be excluded. + /// + /// + public static SwaggerGenOptions IncludeXmlCommentsFromInheritDocs(this SwaggerGenOptions options, params Type[] excludedTypes) + { + var distinctExcludedTypes = excludedTypes?.Distinct().ToArray(); + options.SchemaFilter(options, distinctExcludedTypes); + return options; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.cs b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.cs new file mode 100644 index 0000000..67c603b --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Extensions/SwaggerGenExtensions.cs @@ -0,0 +1,157 @@ +using Swashbuckle.AspNetCore.SwaggerGen; +using System.ComponentModel.DataAnnotations; +using Tingle.AspNetCore.Swagger; +using Tingle.AspNetCore.Swagger.Filters.Documents; +using Tingle.AspNetCore.Swagger.Filters.Operations; +using Tingle.AspNetCore.Swagger.Filters.Parameters; +using Tingle.AspNetCore.Swagger.Filters.Schemas; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for +/// +public static partial class SwaggerGenExtensions +{ + /// + /// Always include a description for BadRequest (400) responses + /// + /// + /// + public static SwaggerGenOptions AlwaysShowBadRequestResponse(this SwaggerGenOptions options) + { + options.OperationFilter(); + return options; + } + + /// + /// Always include a description for Unauthorized (401) and Forbidden (403) responses + /// + /// + /// + public static SwaggerGenOptions AlwaysShowAuthorizationFailedResponse(this SwaggerGenOptions options) + { + options.OperationFilter(); + return options; + } + + /// + /// Add extra tags to operations + /// + /// + /// + public static SwaggerGenOptions AddExtraTags(this SwaggerGenOptions options) + { + options.OperationFilter(); + return options; + } + + /// + /// Adds x-internal when is used + /// + /// + /// + public static SwaggerGenOptions AddInternalOnlyExtensions(this SwaggerGenOptions options) + { + options.ParameterFilter(); + options.SchemaFilter(); + options.OperationFilter(); + return options; + } + + /// + /// Add error codes to operations and the document using the vendor extension x-error-codes + /// + /// + /// + /// The descriptions for error codes. + /// The key () represents the error code whereas + /// the value () represents the description. + /// + /// + /// + /// + public static SwaggerGenOptions AddErrorCodes(this SwaggerGenOptions options, IDictionary? descriptions = null) + { + descriptions ??= new Dictionary(); + options.DocumentFilter(descriptions); + options.OperationFilter(); + return options; + } + + /// + /// Add CorrelationId headers to all operations + /// + /// + /// + /// Flag to indicate if the correlation header (X-Correlation-ID) should be added to an operation's parameters. + /// + /// + /// + /// + public static SwaggerGenOptions AddCorrelationIds(this SwaggerGenOptions options, bool includeInRequests = false) + { + options.DocumentFilter(); + options.OperationFilter(includeInRequests); + return options; + } + + /// + /// Add a groupings for tags to the document using the vendor extension x-tagGroups + /// + /// + /// The tag groups. + /// Whether to add a group for ungrouped tags named Ungrouped + /// + /// + public static SwaggerGenOptions AddTagGroups(this SwaggerGenOptions options, IEnumerable groups, bool addUngrouped = false) + { + options.DocumentFilter(groups, addUngrouped); + return options; + } + + /// + /// Map expected schemas for known types in the framework and from Tingle primitives. These are: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Add a groupings for tags to the document using the vendor extension x-tagGroups + /// + /// + /// + public static SwaggerGenOptions MapFrameworkTypes(this SwaggerGenOptions options) + { + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "date-span", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "ip-address", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "object", AdditionalProperties = new OpenApi.Models.OpenApiSchema(), }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "array", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "object", AdditionalProperties = new OpenApi.Models.OpenApiSchema(), }); + + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "duration", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "int64", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "currency", }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "country", MinLength = 2, MaxLength = 3, }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "language", MinLength = 2, MaxLength = 3, }); + options.MapType(() => new OpenApi.Models.OpenApiSchema { Type = "string", Format = "swift-code", MinLength = 11, MaxLength = 11, }); + + return options; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Extensions/TypeInfoExtensions.cs b/src/Tingle.AspNetCore.Swagger/Extensions/TypeInfoExtensions.cs new file mode 100644 index 0000000..c72712b --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Extensions/TypeInfoExtensions.cs @@ -0,0 +1,39 @@ +namespace System.Reflection; + +internal static class TypeInfoExtensions +{ + public static IEnumerable GetAllConstructors(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredConstructors); + + public static IEnumerable GetAllEvents(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredEvents); + + public static IEnumerable GetAllFields(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredFields); + + public static IEnumerable GetAllMembers(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredMembers); + + public static IEnumerable GetAllMethods(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredMethods); + + public static IEnumerable GetAllNestedTypes(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredNestedTypes); + + public static IEnumerable GetAllProperties(this TypeInfo typeInfo) + => GetAll(typeInfo, ti => ti.DeclaredProperties); + + private static IEnumerable GetAll(TypeInfo typeInfo, Func> accessor) + { + var ti = typeInfo; + while (ti is not null) + { + foreach (var t in accessor(ti)) + { + yield return t; + } + + ti = ti.BaseType?.GetTypeInfo(); + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/ConfigureSwaggerGenEnumDescriptions.cs b/src/Tingle.AspNetCore.Swagger/Filters/ConfigureSwaggerGenEnumDescriptions.cs new file mode 100644 index 0000000..8ff3d85 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/ConfigureSwaggerGenEnumDescriptions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters.Schemas; + +namespace Tingle.AspNetCore.Swagger.Filters; + +/// +/// An for +/// that adds filters which add enum descriptions. +/// This should happen at the last step of configuration so that the comments are already pulled. +/// Hence why the use of . +/// +internal class ConfigureSwaggerGenEnumDescriptions : IPostConfigureOptions +{ + /// + public void PostConfigure(string? name, SwaggerGenOptions options) + { + // Add matching schema filters for enum descriptions after caller + // configuration has been completed so that the target descriptors are present. + foreach (var f in options.SchemaFilterDescriptors.ToArray()) + { + if (f.Type == typeof(XmlCommentsSchemaFilter)) + { + options.SchemaFilterDescriptors.Add(new FilterDescriptor + { + Type = typeof(EnumDescriptionsSchemaFilter), + Arguments = f.Arguments, + }); + } + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/ConfigureSwaggerGenXmlToMarkdown.cs b/src/Tingle.AspNetCore.Swagger/Filters/ConfigureSwaggerGenXmlToMarkdown.cs new file mode 100644 index 0000000..5f06eef --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/ConfigureSwaggerGenXmlToMarkdown.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters.Documents; +using Tingle.AspNetCore.Swagger.Filters.Operations; +using Tingle.AspNetCore.Swagger.Filters.Parameters; +using Tingle.AspNetCore.Swagger.Filters.RequestBodies; +using Tingle.AspNetCore.Swagger.Filters.Schemas; + +namespace Tingle.AspNetCore.Swagger.Filters; + +/// +/// An for +/// that adds filters which convert XML comments to Markdown. +/// This should happen at the last step of configuration so that the comments are already pulled. +/// Hence why the use of . +/// +internal class ConfigureSwaggerGenXmlToMarkdown : IPostConfigureOptions +{ + /// + public void PostConfigure(string? name, SwaggerGenOptions options) + { + options.ParameterFilter(); + options.RequestBodyFilter(); + options.OperationFilter(); + options.SchemaFilter(); + options.DocumentFilter(); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Documents/CorrelationIdDocumentFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Documents/CorrelationIdDocumentFilter.cs new file mode 100644 index 0000000..f7ec720 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Documents/CorrelationIdDocumentFilter.cs @@ -0,0 +1,37 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Documents; + +/// +/// Adds an to a with a description of the X-Correlation-ID header. +/// +/// +public class CorrelationIdDocumentFilter : IDocumentFilter +{ + internal const string HeaderName = "X-Correlation-ID"; + internal const string HeaderDescription = "Used to uniquely identify the HTTP request. This ID is used to correlate the HTTP request between a client and server."; + internal const string HeaderExample = "00-982607166a542147b435be3a847ddd71-fc75498eb9f09d48-00"; + + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + swaggerDoc.Components.Headers[HeaderName] = new OpenApiHeader + { + Description = HeaderDescription, + Schema = new OpenApiSchema { Type = "string", }, + Example = new OpenApiString(HeaderExample), + }; + + swaggerDoc.Components.Parameters[HeaderName] = new OpenApiParameter + { + Description = HeaderDescription, + In = ParameterLocation.Header, + Name = HeaderName, + Required = false, + Schema = new OpenApiSchema { Type = "string", }, + Example = new OpenApiString(HeaderExample), + }; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Documents/ErrorCodesDocumentFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Documents/ErrorCodesDocumentFilter.cs new file mode 100644 index 0000000..f3c5ff4 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Documents/ErrorCodesDocumentFilter.cs @@ -0,0 +1,41 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Documents; + +/// +/// Adds an extension to an with error codes description using the +/// vendor extension x-error-codes +/// +/// +/// +/// The descriptions for error codes. +/// The key () represents the error code whereas +/// the value () represents the description. +/// +public class ErrorCodesDocumentFilter(IDictionary descriptions) : IDocumentFilter +{ + internal const string ExtensionName = "x-error-codes"; + + private readonly IDictionary descriptions = descriptions ?? throw new ArgumentNullException(nameof(descriptions)); + + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + // if there are no errors, do not proceed + if (descriptions.Count <= 0) return; + + var ext = new OpenApiArray(); + foreach (var desc in descriptions) + { + ext.Add(new OpenApiObject + { + ["name"] = new OpenApiString(desc.Key), + ["description"] = new OpenApiString(desc.Value), + }); + }; + + swaggerDoc.Extensions[ExtensionName] = ext; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Documents/MarkdownDocumentFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Documents/MarkdownDocumentFilter.cs new file mode 100644 index 0000000..c292acf --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Documents/MarkdownDocumentFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Documents; + +/// +/// An that converts XML comments to Markdown. +/// +public class MarkdownDocumentFilter : IDocumentFilter +{ + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (swaggerDoc.Tags is null) return; + + foreach (var t in swaggerDoc.Tags) + { + t.Description = XmlCommentsHelper.ToMarkdown(t.Description); + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Documents/ReDocLogoDocumentFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Documents/ReDocLogoDocumentFilter.cs new file mode 100644 index 0000000..4a9a9e7 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Documents/ReDocLogoDocumentFilter.cs @@ -0,0 +1,18 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Documents; + +/// +/// Adds an extension to an with a logo using the vendor extension x-logo +/// +/// +/// +public class ReDocLogoDocumentFilter(OpenApiReDocLogo logo) : IDocumentFilter +{ + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + swaggerDoc.Info.Extensions["x-logo"] = logo; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Documents/TagGroupsDocumentFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Documents/TagGroupsDocumentFilter.cs new file mode 100644 index 0000000..0029b52 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Documents/TagGroupsDocumentFilter.cs @@ -0,0 +1,44 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Documents; + +/// +/// Adds an extension to an with tag groups using the vendor extension x-tagGroups +/// +/// +/// The tag groups. +/// Whether to add a group for ungrouped tags named Ungrouped +public class TagGroupsDocumentFilter(IEnumerable groups, bool addUngrouped = false) : IDocumentFilter +{ + private const string UngroupedGroupName = "ungrouped"; + + private readonly List groups = groups?.ToList() ?? throw new ArgumentNullException(nameof(groups)); + + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (addUngrouped) + { + // find tags that have not been grouped + var operationTags = swaggerDoc.Paths.Select(p => p.Value) + .SelectMany(pi => pi.Operations.Select(op => op.Value)) + .SelectMany(op => op.Tags) + .Select(t => t.Name); + + var docTags = swaggerDoc.Tags.Select(t => t.Name); + var allUniqueTags = docTags.Concat(operationTags).ToHashSet(StringComparer.OrdinalIgnoreCase); + var alreadyGroupedTags = groups.Select(g => g.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var ungroupedTags = allUniqueTags.Except(alreadyGroupedTags, StringComparer.OrdinalIgnoreCase).ToList(); + + // add group for the ungrouped tags so as to ensure they still show up in the documentation + if (ungroupedTags.Count > 0 && !groups.Any(g => g.Name == UngroupedGroupName)) + { + groups.Add(new OpenApiTagGroup(UngroupedGroupName, null, ungroupedTags)); + } + } + + // Add to the swagger spec + swaggerDoc.Extensions["x-tagGroups"] = new OpenApiTagGroups { Groups = groups, }; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/AuthorizationOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/AuthorizationOperationFilter.cs new file mode 100644 index 0000000..3555a1e --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/AuthorizationOperationFilter.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +internal class AuthorizationOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var auth = context.MethodInfo.GetCustomAttributes(inherit: true).OfType(); + var anonymous = context.MethodInfo.GetCustomAttributes(inherit: true).OfType(); + + if (auth.Any() && !anonymous.Any()) + { + // if the operation does not have a response for unauthenticated, create a new one and add it + if (!operation.Responses.TryGetValue("401", out _)) + { + operation.Responses["401"] = new OpenApiResponse + { + Description = "The request is not authenticated", + Headers = new Dictionary + { + [Microsoft.Net.Http.Headers.HeaderNames.WWWAuthenticate] = new OpenApiHeader + { + Description = "The reason why authentication failed", + } + } + }; + } + + // if the operation does not have a response for forbidden, create a new one and add it + if (!operation.Responses.TryGetValue("403", out _)) + { + operation.Responses["403"] = new OpenApiResponse { Description = "Forbidden" }; + } + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/BadRequestOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/BadRequestOperationFilter.cs new file mode 100644 index 0000000..b0d1c62 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/BadRequestOperationFilter.cs @@ -0,0 +1,22 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +internal class BadRequestOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // if the operation does not have a response for bad request, create a new one and add it + if (!operation.Responses.TryGetValue("400", out var response)) + { + response = operation.Responses["400"] = new OpenApiResponse(); + } + + // set the description + if (string.IsNullOrWhiteSpace(response.Description)) + { + response.Description = "The request is invalid, see response for more details."; + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/CorrelationIdOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/CorrelationIdOperationFilter.cs new file mode 100644 index 0000000..634be23 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/CorrelationIdOperationFilter.cs @@ -0,0 +1,45 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters.Documents; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +/// +/// Adds an to all instances of in an +/// operation with a description of the X-Correlation-ID header. +/// +/// +/// +/// Flag to indicate if the correlation header (X-Correlation-ID) should be added to an operation's parameters. +/// +public class CorrelationIdOperationFilter(bool includeInRequests) : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (includeInRequests) + { + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Id = CorrelationIdDocumentFilter.HeaderName, + Type = ReferenceType.Parameter, + } + }); + } + + foreach (var kvp in operation.Responses) + { + var r = kvp.Value; + r.Headers[CorrelationIdDocumentFilter.HeaderName] = new OpenApiHeader + { + Reference = new OpenApiReference + { + Id = CorrelationIdDocumentFilter.HeaderName, + Type = ReferenceType.Header, + } + }; + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/ErrorCodesOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/ErrorCodesOperationFilter.cs new file mode 100644 index 0000000..a6beed6 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/ErrorCodesOperationFilter.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters.Documents; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +/// +/// Adds an extension to an with error codes using +/// the vendor extension x-error-codes +/// +/// +public class ErrorCodesOperationFilter : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var attributes = new List(); + + // get attributes from the method + var methodAttributes = context.MethodInfo.GetCustomAttributes(inherit: true).OfType(); + attributes.AddRange(methodAttributes); + + // get attributes from the controller + if (context.ApiDescription.ActionDescriptor is ControllerActionDescriptor cad) + { + var controllerAttributes = cad.ControllerTypeInfo.GetCustomAttributes(inherit: true).OfType(); + attributes.AddRange(controllerAttributes); + } + + // make unique error codes + var uniqueErrorCodes = attributes.SelectMany(attr => attr.Errors) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // if there are no errors, do not proceed + if (uniqueErrorCodes.Count <= 0) return; + + var ext = new OpenApiArray(); + foreach (var code in uniqueErrorCodes) + { + ext.Add(new OpenApiString(code)); + } + + operation.Extensions[ErrorCodesDocumentFilter.ExtensionName] = ext; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/ExtraTagsOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/ExtraTagsOperationFilter.cs new file mode 100644 index 0000000..f917c6f --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/ExtraTagsOperationFilter.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +/// +/// Adds instances of to an for all extra tags defined by +/// using on the controller or action +/// +/// +internal class ExtraTagsOperationFilter : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var attributes = new List(); + + // get attributes from the method + var methodAttributes = context.MethodInfo.GetCustomAttributes(inherit: true).OfType(); + attributes.AddRange(methodAttributes); + + // get attributes from the controller + if (context.ApiDescription.ActionDescriptor is ControllerActionDescriptor cad) + { + var controllerAttributes = cad.ControllerTypeInfo.GetCustomAttributes(inherit: true).OfType(); + attributes.AddRange(controllerAttributes); + } + + // make the attributes unique by name + var uniqueAttributes = attributes.ToDictionary(attr => attr.Name, StringComparer.OrdinalIgnoreCase); + + operation.Tags ??= new List(); + + foreach (var kvp in uniqueAttributes) + { + var attr = kvp.Value; + + operation.Tags.Add(new OpenApiTag + { + Name = attr.Name, + Description = attr.Description, + ExternalDocs = attr.ExternalDocsUrl != null + ? new OpenApiExternalDocs { Url = new Uri(attr.ExternalDocsUrl) } + : null, + }); + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/InternalOnlyOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/InternalOnlyOperationFilter.cs new file mode 100644 index 0000000..32dec68 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/InternalOnlyOperationFilter.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +/// +/// An that decorates instances +/// with x-internal when is present. +/// +public class InternalOnlyOperationFilter : IOperationFilter +{ + internal const string ExtensionName = "x-internal"; + + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // Check if the method or controller has the attribute declared/annotated + var attr = context.MethodInfo.GetCustomAttribute(inherit: true); + if (attr is null && context.ApiDescription.ActionDescriptor is ControllerActionDescriptor cad) + { + attr = cad.ControllerTypeInfo.GetCustomAttribute(inherit: true); + } + if (attr is null) return; + + // At this point, the API is internal only, so just set the extension value + operation.Extensions[ExtensionName] = new OpenApiBoolean(true); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Operations/MarkdownOperationFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Operations/MarkdownOperationFilter.cs new file mode 100644 index 0000000..69f28e8 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Operations/MarkdownOperationFilter.cs @@ -0,0 +1,22 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Operations; + +/// +/// An that converts XML comments to Markdown. +/// +public class MarkdownOperationFilter : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + operation.Summary = XmlCommentsHelper.ToMarkdown(operation.Summary); + operation.Description = XmlCommentsHelper.ToMarkdown(operation.Description); + foreach (var kvp in operation.Responses) + { + var response = kvp.Value; + response.Description = XmlCommentsHelper.ToMarkdown(response.Description); + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Parameters/InternalOnlyParameterFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Parameters/InternalOnlyParameterFilter.cs new file mode 100644 index 0000000..4e1bab8 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Parameters/InternalOnlyParameterFilter.cs @@ -0,0 +1,27 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Tingle.AspNetCore.Swagger.Filters.Operations; + +namespace Tingle.AspNetCore.Swagger.Filters.Parameters; + +/// +/// An that decorates instances +/// with x-internal when is present. +/// +public class InternalOnlyParameterFilter : IParameterFilter +{ + /// + public void Apply(OpenApiParameter parameter, ParameterFilterContext context) + { + // Check if the type has the attribute declared/annotated + var attr = context.PropertyInfo?.GetCustomAttribute(inherit: true) + ?? context.ParameterInfo?.GetCustomAttribute(inherit: true); + if (attr is null) return; + + // At this point, the API is internal only, so just set the extension value + parameter.Extensions[InternalOnlyOperationFilter.ExtensionName] = new OpenApiBoolean(true); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Parameters/MarkdownParameterFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Parameters/MarkdownParameterFilter.cs new file mode 100644 index 0000000..9d26e35 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Parameters/MarkdownParameterFilter.cs @@ -0,0 +1,16 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Parameters; + +/// +/// An that converts XML comments to Markdown. +/// +public class MarkdownParameterFilter : IParameterFilter +{ + /// + public void Apply(OpenApiParameter parameter, ParameterFilterContext context) + { + parameter.Description = XmlCommentsHelper.ToMarkdown(parameter.Description); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/RequestBodies/MarkdownRequestBodyFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/RequestBodies/MarkdownRequestBodyFilter.cs new file mode 100644 index 0000000..9bb99ec --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/RequestBodies/MarkdownRequestBodyFilter.cs @@ -0,0 +1,16 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.RequestBodies; + +/// +/// An that converts XML comments to Markdown. +/// +public class MarkdownRequestBodyFilter : IRequestBodyFilter +{ + /// + public void Apply(OpenApiRequestBody requestBody, RequestBodyFilterContext context) + { + requestBody.Description = XmlCommentsHelper.ToMarkdown(requestBody.Description); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Schemas/EnumDescriptionsSchemaFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/EnumDescriptionsSchemaFilter.cs new file mode 100644 index 0000000..0038f7d --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/EnumDescriptionsSchemaFilter.cs @@ -0,0 +1,81 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; +using System.Runtime.Serialization; +using System.Xml.XPath; + +namespace Tingle.AspNetCore.Swagger.Filters.Schemas; + +/// +/// An implementation of that adds descriptions for enums. +/// +/// +public class EnumDescriptionsSchemaFilter(XPathDocument xmlDoc) : ISchemaFilter +{ + internal const string ExtensionName = "x-enumDescriptions"; + + private readonly XPathNavigator _xmlNavigator = xmlDoc.CreateNavigator(); + + /// + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (context.Type is null || !context.Type.IsEnum) return; + if (schema.Enum is null || schema.Enum.Count == 0) return; + + var extension = schema.Extensions.TryGetValue(ExtensionName, out var ext) ? (OpenApiObject)ext : []; + + var enumType = context.Type; + var enumValues = Enum.GetValues(enumType); + foreach (var enumValue in enumValues) + { + var memberInfo = enumType.GetMembers().Single(m => m.Name.Equals(enumValue.ToString())); + var description = TryGetXmlComments(memberInfo, _xmlNavigator); + if (description is not null) + { + // find the enum from this in the schema + var possibleValues = MakePossibleValues(memberInfo).ToList(); + var found = schema.Enum.OfType().SingleOrDefault(v => possibleValues.Contains(v.Value, StringComparer.OrdinalIgnoreCase))?.Value; + + // add description to the extension if the enum is exposed + if (found is not null) + { + extension[found] = new OpenApiString(description); + } + } + } + + // Add the extension if there descriptions + if (extension.Count > 0) + { + schema.Extensions[ExtensionName] = extension; + } + } + + private static string? TryGetXmlComments(MemberInfo memberInfo, XPathNavigator _xmlNavigator) + { + var enumMemberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(memberInfo); + var enumNode = _xmlNavigator.SelectSingleNode($"/doc/members/member[@name='{enumMemberName}']"); + + if (enumNode is null) return null; + + var summaryNode = enumNode.SelectSingleNode("summary"); + if (summaryNode != null) + { + var xml = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml); + return XmlCommentsHelper.ToMarkdown(xml); + } + + return null; + } + + private static IEnumerable MakePossibleValues(MemberInfo memberInfo) + { + ArgumentNullException.ThrowIfNull(memberInfo); + + var attr = memberInfo.GetCustomAttribute(inherit: false); + + yield return memberInfo.Name; + if (attr?.Value is not null) yield return attr.Value; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Schemas/InheritDocSchemaFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/InheritDocSchemaFilter.cs new file mode 100644 index 0000000..775bd44 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/InheritDocSchemaFilter.cs @@ -0,0 +1,199 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; +using System.Xml.XPath; + +namespace Tingle.AspNetCore.Swagger.Filters.Schemas; + +/// +/// Adds documentation that is provided by the <inhertidoc /> tag. +/// +/// +public class InheritDocSchemaFilter : ISchemaFilter +{ + private const string SummaryTag = "summary"; + private const string ExampleTag = "example"; + + private readonly List documents; + private readonly Dictionary inheritedDocs; + private readonly Type[] excludedTypes; + + /// + /// Initializes a new instance of the class. + /// + /// . + /// Excluded types. + public InheritDocSchemaFilter(SwaggerGenOptions options, params Type[] excludedTypes) + { + this.excludedTypes = excludedTypes; + + // find the XPathDocument arguments from all XmlCommentSchemaFilters + documents = options.SchemaFilterDescriptors.Where(x => x.Type == typeof(XmlCommentsSchemaFilter)) + .Select(x => x.Arguments.Single()) + .Cast() + .ToList(); + + inheritedDocs = documents.SelectMany(doc => + { + var inheritedElements = new List<(string, string)>(); + foreach (XPathNavigator member in doc.CreateNavigator().Select("doc/members/member/inheritdoc")) + { + var cref = member.GetAttribute("cref", ""); + member.MoveToParent(); + var parentCref = member.GetAttribute("cref", ""); + if (!string.IsNullOrWhiteSpace(parentCref)) + cref = parentCref; + inheritedElements.Add((member.GetAttribute("name", ""), cref)); + } + + return inheritedElements; + }).ToDictionary(x => x.Item1, x => x.Item2); + } + + /// + /// Apply filter. + /// + /// . + /// . + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (excludedTypes.Any() && excludedTypes.Contains(context.Type)) return; + + // Try to apply a description for inherited types. + var memberName = XmlCommentsNodeNameHelper.GetMemberNameForType(context.Type); + if (string.IsNullOrEmpty(schema.Description) && inheritedDocs.TryGetValue(memberName, out var cref)) + { + var target = GetTargetRecursive(context.Type, cref); + + var targetXmlNode = GetMemberXmlNode(XmlCommentsNodeNameHelper.GetMemberNameForType(target)); + var summaryNode = targetXmlNode?.SelectSingleNode(SummaryTag); + + if (summaryNode != null) + { + schema.Description = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml); + } + } + + // Handle parameters such as in form data + if (context.ParameterInfo != null && context.MemberInfo != null) + { + ApplyPropertyComments(schema, context.MemberInfo); + } + + if (schema.Properties == null) return; + + // Add the summary and examples for the properties. + foreach (var entry in schema.Properties) + { + var memberInfo = ((TypeInfo)context.Type).GetAllMembers()?.FirstOrDefault(p => p.Name.Equals(entry.Key, StringComparison.OrdinalIgnoreCase)); + if (memberInfo != null) + { + ApplyPropertyComments(entry.Value, memberInfo); + } + } + } + + private void ApplyPropertyComments(OpenApiSchema propertySchema, MemberInfo memberInfo) + { + var memberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(memberInfo); + + if (!inheritedDocs.ContainsKey(memberName)) return; + if (excludedTypes.Any() && excludedTypes.Contains(((PropertyInfo)memberInfo).PropertyType)) return; + + var cref = inheritedDocs[memberName]; + var target = GetTargetRecursive(memberInfo, cref); + + var targetXmlNode = GetMemberXmlNode(XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(target)); + if (targetXmlNode == null) return; + + var summaryNode = targetXmlNode.SelectSingleNode(SummaryTag); + if (string.IsNullOrEmpty(propertySchema.Description) && summaryNode != null) + { + propertySchema.Description = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml); + } + + var exampleNode = targetXmlNode.SelectSingleNode(ExampleTag); + if (propertySchema.Example is null && exampleNode != null) + { + propertySchema.Example = new OpenApiString(XmlCommentsTextHelper.Humanize(exampleNode.InnerXml)); + } + } + + private XPathNavigator? GetMemberXmlNode(string memberName) + { + var path = $"/doc/members/member[@name='{memberName}']"; + + foreach (var document in documents) + { + var node = document.CreateNavigator().SelectSingleNode(path); + if (node != null) return node; + } + + return null; + } + + private static MemberInfo? GetTarget(MemberInfo memberInfo, string cref) + { + var type = memberInfo.DeclaringType ?? memberInfo.ReflectedType; + if (type == null) return null; + + // Find all matching members in all interfaces and the base class. + var targets = type.GetInterfaces() + .Append(type.BaseType) + .SelectMany( + x => x?.FindMembers( + memberInfo.MemberType, + BindingFlags.Instance | BindingFlags.Public, + (info, criteria) => info.Name == memberInfo.Name, + null) ?? []) + .ToList(); + + // Try to find the target, if one is declared. + if (!string.IsNullOrEmpty(cref)) + { + var crefTarget = targets.SingleOrDefault(t => XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(t) == cref); + if (crefTarget != null) return crefTarget; + } + + // We use the last since that will be our base class or the "nearest" implemented interface. + return targets.LastOrDefault(); + } + + private MemberInfo? GetTargetRecursive(MemberInfo memberInfo, string cref) + { + var target = GetTarget(memberInfo, cref); + if (target == null) return null; + + var targetMemberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(target); + + return inheritedDocs.TryGetValue(targetMemberName, out var value) ? GetTarget(target, value) : target; + } + + private Type? GetTargetRecursive(Type type, string cref) + { + var target = GetTarget(type, cref); + if (target == null) return null; + + var targetMemberName = XmlCommentsNodeNameHelper.GetMemberNameForType(target); + + return inheritedDocs.TryGetValue(targetMemberName, out var value) ? GetTarget(target, value) : target; + } + + private static Type? GetTarget(Type type, string cref) + { + var targets = type.GetInterfaces(); + if (type.BaseType is not null && type.BaseType != typeof(object)) + targets = targets.Append(type.BaseType).ToArray(); + + // Try to find the target, if one is declared. + if (!string.IsNullOrEmpty(cref)) + { + var crefTarget = targets.SingleOrDefault(t => XmlCommentsNodeNameHelper.GetMemberNameForType(t) == cref); + if (crefTarget != null) return crefTarget; + } + + // We use the last since that will be our base class or the "nearest" implemented interface. + return targets.LastOrDefault(); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Schemas/InternalOnlySchemaFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/InternalOnlySchemaFilter.cs new file mode 100644 index 0000000..81a1208 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/InternalOnlySchemaFilter.cs @@ -0,0 +1,28 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Tingle.AspNetCore.Swagger.Filters.Operations; + +namespace Tingle.AspNetCore.Swagger.Filters.Schemas; + +/// +/// An that decorates instances +/// with x-internal when is present. +/// +public class InternalOnlySchemaFilter : ISchemaFilter +{ + /// + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + // Check if the type has the attribute declared/annotated + var attr = context.MemberInfo?.GetCustomAttribute(inherit: true) + ?? context.ParameterInfo?.GetCustomAttribute(inherit: true) + ?? context.Type.GetCustomAttribute(inherit: true); + if (attr is null) return; + + // At this point, the API is internal only, so just set the extension value + schema.Extensions[InternalOnlyOperationFilter.ExtensionName] = new OpenApiBoolean(true); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Filters/Schemas/MarkdownSchemaFilter.cs b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/MarkdownSchemaFilter.cs new file mode 100644 index 0000000..41c74f9 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Filters/Schemas/MarkdownSchemaFilter.cs @@ -0,0 +1,43 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Tingle.AspNetCore.Swagger.Filters.Schemas; + +/// +/// An that converts XML comments to Markdown. +/// +public class MarkdownSchemaFilter : ISchemaFilter +{ + /// + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + schema.Description = XmlCommentsHelper.ToMarkdown(schema.Description); + + // The InheritDocSchemaFilter modifies all the properties so we need to do the same here + + // Handle parameters such as in form data + if (context.ParameterInfo != null && context.MemberInfo != null) + { + ApplyPropertyComments(schema); + } + + if (schema.Properties == null) return; + + // Add the summary and examples for the properties. + foreach (var entry in schema.Properties) + { + ApplyPropertyComments(entry.Value); + } + } + + private static void ApplyPropertyComments(OpenApiSchema propertySchema) + { + propertySchema.Description = XmlCommentsHelper.ToMarkdown(propertySchema.Description); + + if (propertySchema.Example is OpenApiString str) + { + propertySchema.Example = new OpenApiString(XmlCommentsHelper.ToMarkdown(str.Value)); + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/Mvc/InternalOnlyAttribute.cs b/src/Tingle.AspNetCore.Swagger/Mvc/InternalOnlyAttribute.cs new file mode 100644 index 0000000..e0495b9 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Mvc/InternalOnlyAttribute.cs @@ -0,0 +1,11 @@ +namespace System.ComponentModel.DataAnnotations; + +/// +/// Indicates that a class or method is for internal use only. +/// +[AttributeUsage( + // controllers; enum members; schemas (whole class/struct or properties); action methods, delegates (minimal APIs), and their parameters + AttributeTargets.Class | AttributeTargets.Field | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, + AllowMultiple = false, + Inherited = true)] +public sealed class InternalOnlyAttribute : Attribute { } diff --git a/src/Tingle.AspNetCore.Swagger/Mvc/OperationErrorCodesAttribute.cs b/src/Tingle.AspNetCore.Swagger/Mvc/OperationErrorCodesAttribute.cs new file mode 100644 index 0000000..bd51f5f --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Mvc/OperationErrorCodesAttribute.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc; + +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class OperationErrorCodesAttribute(params string[] errors) : Attribute +{ + /// + public string[] Errors { get; set; } = errors; +} diff --git a/src/Tingle.AspNetCore.Swagger/Mvc/OperationExtraTagAttribute.cs b/src/Tingle.AspNetCore.Swagger/Mvc/OperationExtraTagAttribute.cs new file mode 100644 index 0000000..2c78053 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Mvc/OperationExtraTagAttribute.cs @@ -0,0 +1,15 @@ +namespace Microsoft.AspNetCore.Mvc; + +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class OperationExtraTagAttribute(string name) : Attribute +{ + /// + public string Name { get; set; } = name ?? throw new ArgumentNullException(nameof(name)); + + /// + public string? Description { get; set; } + + /// + public string? ExternalDocsUrl { get; set; } +} diff --git a/src/Tingle.AspNetCore.Swagger/OpenApiReDocLogo.cs b/src/Tingle.AspNetCore.Swagger/OpenApiReDocLogo.cs new file mode 100644 index 0000000..fe682b1 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/OpenApiReDocLogo.cs @@ -0,0 +1,69 @@ +using Microsoft.OpenApi; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; + +namespace Tingle.AspNetCore.Swagger; + +/// +/// Represents a model for configuring a logo using the vendor extension x-logo +/// +public class OpenApiReDocLogo : IOpenApiExtension +{ + /// + /// The URL pointing to the spec logo. + /// MUST be in the format of a URL. + /// It SHOULD be an absolute URL so your API definition is usable from any location + /// + public string? Url { get; set; } + + /// + /// The background color to be used. MUST be RGB color in [hexadecimal format] + /// (https://en.wikipedia.org/wiki/Web_colors#Hex_triplet) + /// + public string? BackgroundColor { get; set; } + + /// + /// Text to use for alt tag on the logo. Defaults to 'logo' if nothing is provided. + /// + public string? AltText { get; set; } + + /// + /// The URL pointing to the contact page. Default to 'info.contact.url' field of the OAS. + /// + public string? Href { get; set; } + + /// + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(writer); + + writer.WriteStartObject(); + + // URL + if (!string.IsNullOrWhiteSpace(Url)) + { + writer.WriteProperty(OpenApiConstants.Url, Url); + } + + // backgroundColor + if (!string.IsNullOrWhiteSpace(BackgroundColor)) + { + writer.WriteProperty("backgroundColor", BackgroundColor); + } + + // altText + if (!string.IsNullOrEmpty(AltText)) + { + writer.WriteProperty("altText", AltText); + } + + // href + if (!string.IsNullOrEmpty(Href)) + { + writer.WriteProperty("href", Href); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/OpenApiTagGroup.cs b/src/Tingle.AspNetCore.Swagger/OpenApiTagGroup.cs new file mode 100644 index 0000000..b70dc19 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/OpenApiTagGroup.cs @@ -0,0 +1,62 @@ +using Microsoft.OpenApi.Models; + +namespace Tingle.AspNetCore.Swagger; + +/// +/// Represents a tag group to be added to the generated via . +/// +public class OpenApiTagGroup +{ + /// Creates an instance of . + /// The name of the tag. group + /// The tags in the group. + /// Whether the tag group is an internal one. + public OpenApiTagGroup(string name, IEnumerable tags, bool @internal = false) + : this(name: name, description: null, tags: tags, @internal: @internal) { } + + /// Creates an instance of . + /// The name of the tag group. + /// The tags in the group. + /// Whether the tag group is an internal one. + public OpenApiTagGroup(string name, List tags, bool @internal = false) + : this(name: name, description: null, tags: tags, @internal: @internal) { } + + /// Creates an instance of . + /// The name of the tag group. + /// The description of the tag group (optional). + /// The tags in the group. + /// Whether the tag group is an internal one. + public OpenApiTagGroup(string name, string? description, IEnumerable tags, bool @internal = false) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description; + Tags = tags.Select(m => new OpenApiReference { Id = m, Type = ReferenceType.Tag, }) + .ToList(); + Internal = @internal; + } + + /// Creates an instance of . + /// The name of the tag group. + /// The description of the tag group (optional). + /// The tags in the group. + /// Whether the tag group is an internal one. + public OpenApiTagGroup(string name, string? description, List tags, bool @internal = false) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description; + Tags = tags ?? throw new ArgumentNullException(nameof(tags)); + Internal = @internal; + } + + /// The name of the tag group. + public string Name { get; init; } + + /// The description of the tag group (optional). + public string? Description { get; init; } + + /// The tags in the group. + public List Tags { get; init; } + + /// Whether the tag group is an internal one. + public bool Internal { get; init; } +} diff --git a/src/Tingle.AspNetCore.Swagger/OpenApiTagGroups.cs b/src/Tingle.AspNetCore.Swagger/OpenApiTagGroups.cs new file mode 100644 index 0000000..da00f39 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/OpenApiTagGroups.cs @@ -0,0 +1,50 @@ +using Microsoft.OpenApi; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; + +namespace Tingle.AspNetCore.Swagger; + +/// +/// Represents tag groups to be added to the generated . +/// +public class OpenApiTagGroups : IOpenApiExtension +{ + /// + /// The tag groups to be written. + /// + public IList? Groups { get; set; } + + /// + public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(writer); + if (Groups is null || Groups.Count == 0) return; + + writer.WriteStartArray(); + + foreach (var item in Groups) + { + writer.WriteStartObject(); + + // name + writer.WriteProperty(OpenApiConstants.Name, item.Name); + + // description + if (item.Description is not null) + { + writer.WriteProperty(OpenApiConstants.Description, item.Description); + } + + // internal + writer.WriteProperty(Filters.Operations.InternalOnlyOperationFilter.ExtensionName, item.Internal, defaultValue: false); + + // tags + writer.WriteRequiredCollection(OpenApiConstants.Tags, item.Tags, (w, s) => s!.SerializeAsV3(w)); + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/README.md b/src/Tingle.AspNetCore.Swagger/README.md new file mode 100644 index 0000000..a4d46cb --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/README.md @@ -0,0 +1,57 @@ +# Tingle.AspNetCore.Swagger + +Swagger is a specification for documenting your API and specifies the format (URL, method, and representation) used to describe REST web services. This library is used to generate API documentation as well as add API documentation UI through ReDoc. + +The library includes the `Swashbuckle.AspNetCore.SwaggerGen` library which is a Swagger generator that builds the `SwaggerDocument` objects directly from your routes, controllers, and models. It is combined with the Swagger endpoint middleware to automatically expose Swagger JSON. + +Swashbuckle relies heavily on `ApiExplorer` (the API metadata layer that ships with ASP.NET Core). If using `AddMvcCore` for a more pared down MVC stack, you'll need to explicitly add the `ApiExplorer` service. + +In `ConfigureServices` method of `Program.cs`, add `MvcCore` service, the `ApiExplorer` service, and register the Swagger generator defining one or more Swagger documents. + +```cs +// Add swagger documents generation +builder.Services.AddSwaggerGen(options => +{ + options + .AddDocuments(services.BuildServiceProvider(), ConstStrings.ApiDocsTitle, System.IO.File.ReadAllText("apidesc.md")) + .AlwaysShowAuthorizationFailedResponse() + .AlwaysShowBadRequestResponse(); + + options.IncludeXmlComments(); +}); +``` + +In the `Configure` method of `Program.cs`, insert middleware to expose the generated Swagger as JSON endpoints. + +```cs +// Add swagger schema docs +app.MapSwagger(options => options.PreSerializeFilters.Add((swaggerDoc, httpRequest) => swaggerDoc.Host = httpRequest.Host.Value)); +``` + +In the code snippet above, the `SwaggerDocument` and the current `HttpRequest` are passed to the filter thus providing a lot of flexibility. The filter will be executed prior to serializing the document. + +ReDoc functionalities are described in the sections below: + +## ReDoc + +ReDoc adds API documentation UI. It can be used to document complex request/response payloads. It also supports nested schemas and displays them in place with the ability to collapse or expand. + +This library is used to add ReDoc middleware to the HTTP request pipeline. This adds API documentation UI. + +## Setup + +Add the following code logic to `Program.cs` + +```cs +// Add ReDoc services +builder.Services.AddReDoc(o => +{ + o.DocumentTitle = ConstStrings.ApiDocsTitle; + o.DefaultDocumentName = $"v{ConstStrings.ApiVersion2}"; +}); + +var app = builder.Build(); + +// Add API documentation UI via ReDoc +app.MapReDoc(); +``` diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocConfig.cs b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocConfig.cs new file mode 100644 index 0000000..47e1bbb --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocConfig.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.Swagger.ReDoc; + +/// +/// Configuration object for use with to configure the ReDoc UI in +/// the browser produced by +/// +public class ReDocConfig +{ + /// + /// If set, the spec is considered untrusted and all HTML/markdown is sanitized to prevent XSS. + /// Disabled by default for performance reasons. Enable this option if you work with untrusted user data! + /// + [JsonPropertyName("untrustedSpec")] + public bool UntrustedSpec { get; set; } = false; + + /// + /// If set, specifies a vertical scroll-offset in pixels. + /// This is often useful when there are fixed positioned elements at the top of the page, such as navbars, headers etc + /// + [JsonPropertyName("scrollYOffset")] + public int? ScrollYOffset { get; set; } + + /// + /// If set, the protocol and hostname is not shown in the operation definition + /// + [JsonPropertyName("hideHostname")] + public bool HideHostname { get; set; } = false; + + /// + /// Do not show "Download" spec button. THIS DOESN'T MAKE YOUR SPEC PRIVATE, it just hides the button + /// + [JsonPropertyName("hideDownloadButton")] + public bool HideDownloadButton { get; set; } = false; + + /// + /// Specify which responses to expand by default by response codes. + /// Values should be passed as comma-separated list without spaces e.g. "200,201". Special value "all" expands all responses by default. + /// Be careful: this option can slow-down documentation rendering time. + /// + [JsonPropertyName("expandResponses")] + public string ExpandResponses { get; set; } = "200,201"; + + /// + /// Show required properties first ordered in the same order as in required array + /// + [JsonPropertyName("requiredPropsFirst")] + public bool RequiredPropsFirst { get; set; } = false; + + /// + /// Do not inject Authentication section automatically + /// + [JsonPropertyName("noAutoAuth")] + public bool NoAutoAuth { get; set; } = false; + + /// + /// Show path link and HTTP verb in the middle panel instead of the right one + /// + [JsonPropertyName("pathInMiddlePanel")] + public bool PathInMiddlePanel { get; set; } = false; + + /// + /// Do not show loading animation. Useful for small docs + /// + [JsonPropertyName("hideLoading")] + public bool HideLoading { get; set; } = false; + + /// + /// Use native scrollbar for sidemenu instead of perfect-scroll (scrolling performance optimization for big specs) + /// + [JsonPropertyName("nativeScrollbars")] + public bool NativeScrollbars { get; set; } = false; + + /// + /// Disable search indexing and search box + /// + [JsonPropertyName("disableSearch")] + public bool DisableSearch { get; set; } = false; + + /// + /// Show only required fields in request samples + /// + [JsonPropertyName("onlyRequiredInSamples")] + public bool OnlyRequiredInSamples { get; set; } = false; + + /// + /// Sort properties alphabetically + /// + [JsonPropertyName("sortPropsAlphabetically")] + public bool SortPropsAlphabetically { get; set; } = false; + + /// + /// Show vendor extensions ("x-" fields). Extensions used by ReDoc are ignored. + /// Can be boolean or an array of string with names of extensions to display + /// + [JsonPropertyName("showExtensions")] + [JsonConverter(typeof(AnyOfBooleanOrStringArrayJsonConverter))] + public AnyOfTypes.AnyOf>? ShowExtensions { get; set; } = false; + + /// + /// The extras (JSON Extension Data) + /// + [JsonExtensionData] + public Dictionary AdditionalItems { get; set; } = []; +} + +internal partial class AnyOfBooleanOrStringArrayJsonConverter : JsonConverter>> +{ + public override AnyOfTypes.AnyOf> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, AnyOfTypes.AnyOf> value, JsonSerializerOptions options) + { + if (value == default) writer.WriteNullValue(); + else if (value.CurrentValue is bool b) writer.WriteBooleanValue(b); + else if (value.CurrentValue is ICollection col) + { + writer.WriteStartArray(); + foreach (var item in col) + { + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + return; + } + } +} diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocConfigureOptions.cs b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocConfigureOptions.cs new file mode 100644 index 0000000..c60ddb1 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocConfigureOptions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Options; +using Tingle.AspNetCore.Swagger.ReDoc; + +namespace Microsoft.Extensions.DependencyInjection; + +internal class ReDocConfigureOptions : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ReDocOptions options) + { + if (string.IsNullOrWhiteSpace(options.SpecUrlTemplate)) + { + return ValidateOptionsResult.Fail($"'{nameof(options.SpecUrlTemplate)}' must be provided"); + } + + if (string.IsNullOrWhiteSpace(options.ScriptUrl)) + { + return ValidateOptionsResult.Fail($"'{nameof(options.ScriptUrl)}' must be provided"); + } + + if (!options.SpecUrlTemplate.Contains("{documentName}")) + { + return ValidateOptionsResult.Fail($"'{nameof(options.SpecUrlTemplate)}'must contain {{documentName}}"); + } + + return ValidateOptionsResult.Success; + } +} \ No newline at end of file diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocEndpointRouteBuilderExtensions.cs b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..daf9723 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocEndpointRouteBuilderExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Tingle.AspNetCore.Swagger.ReDoc; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Provides extension methods for to add ReDoc. +/// +public static class ReDocEndpointRouteBuilderExtensions +{ + /// + /// Adds a ReDoc endpoint to the with the default pattern.. + /// The default template is '/docs/{documentName=v1}' + /// + /// The to add the ReDoc endpoint to. + /// The action to setup options for the endpoint. + /// A convention routes for the ReDoc endpoint. + public static IEndpointConventionBuilder MapReDoc(this IEndpointRouteBuilder endpoints, + Action? setupAction = null) + { + return endpoints.MapReDoc("/docs/{documentName=v1}", setupAction); + } + + /// + /// Adds a ReDoc endpoint to the with the specified template. + /// + /// The to add the ReDoc endpoint to. + /// The URL pattern of the ReDoc endpoint. Must include the {documentName} parameter. + /// The action to setup options for the endpoint. + /// A convention routes for the ReDoc endpoint. + public static IEndpointConventionBuilder MapReDoc(this IEndpointRouteBuilder endpoints, + string pattern, + Action? setupAction = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + + // ensure pattern contains {documentName} + if (!RoutePatternFactory.Parse(pattern).Parameters.Any(x => x.Name == "documentName")) + { + throw new ArgumentException( + $"The {nameof(pattern)} must contain '{{documentName}}' parameter." + + "Try something similar to '/docs/{documentName=v1}'", + nameof(pattern)); + } + + var builder = endpoints.CreateApplicationBuilder(); + + if (setupAction == null) + { + // Don't pass options so it can be configured/injected via DI container instead + builder.UseMiddleware(); + } + else + { + // Configure an options instance here and pass directly to the middleware + var options = new ReDocOptions(); + setupAction.Invoke(options); + + // ensure pattern contains {documentName} + if (!RoutePatternFactory.Parse(options.SpecUrlTemplate).Parameters.Any(x => x.Name == "documentName")) + { + throw new InvalidOperationException( + $"The {nameof(options.SpecUrlTemplate)} must contain '{{documentName}}' parameter."); + } + + builder.UseMiddleware(options); + } + + return endpoints.MapGet(pattern, builder.Build()).WithDisplayName("redoc"); + } +} diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocMiddleware.cs b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocMiddleware.cs new file mode 100644 index 0000000..fb47b9f --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocMiddleware.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; +using SC = Tingle.AspNetCore.Swagger.SwaggerJsonSerializerContext; + +#pragma warning disable CS9113 // Parameter is unread. + +namespace Tingle.AspNetCore.Swagger.ReDoc; + +internal class ReDocMiddleware(RequestDelegate _, IOptions optionsAccessor) +{ + private readonly ReDocOptions options = optionsAccessor?.Value ?? throw new ArgumentNullException(nameof(optionsAccessor)); + + public async Task Invoke(HttpContext httpContext) + { + var routeValues = httpContext.Request.RouteValues; + + // extract the documentName + var documentName = routeValues.GetValueOrDefault("documentName")?.ToString(); + + // formulate the URL for the spec (JSON or YAML) + var specUrl = options.SpecUrlTemplate.Replace("{documentName}", documentName); + + // write the response + var response = httpContext.Response; + using var stream = options.IndexStream(); + // Inject arguments before writing to response + var htmlBuilder = new StringBuilder(new StreamReader(stream).ReadToEnd()); + foreach (var entry in GetIndexArguments(specUrl)) + { + htmlBuilder.Replace(entry.Key, entry.Value); + } + + response.StatusCode = 200; + response.ContentType = "text/html"; + await response.WriteAsync(htmlBuilder.ToString(), Encoding.UTF8).ConfigureAwait(false); + } + + private Dictionary GetIndexArguments(string specUrl) + { + return new Dictionary() + { + { "%(DocumentTitle)", options.DocumentTitle }, + { "%(HeadContent)", options.HeadContent }, + { "%(SpecUrl)", specUrl }, + { "%(ScriptUrl)", options.ScriptUrl }, + { "%(Config)", JsonSerializer.Serialize(options.Config, SC.Default.ReDocConfig) } + }; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocOptions.cs b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocOptions.cs new file mode 100644 index 0000000..3939eba --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocOptions.cs @@ -0,0 +1,44 @@ +using System.Reflection; + +namespace Tingle.AspNetCore.Swagger.ReDoc; + +/// +/// Configuration options for use with +/// +public class ReDocOptions +{ + /// + /// The template for the swagger document. Must include the {documentName} parameter. + /// Defaults to '/swagger/{documentName}/swagger.json'. + /// + public string SpecUrlTemplate { get; set; } = "/swagger/{documentName}/swagger.json"; + + /// + /// The title of the page. + /// Defaults to 'API Docs'. + /// + public string DocumentTitle { get; set; } = "API Docs"; + + /// + /// Gets or sets additional content to place in the head of the redoc page. + /// Defaults to empty string + /// + public string HeadContent { get; set; } = ""; + + /// + /// The Url for the script. + /// Defaults to the latest servered via CDN + /// + public string ScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"; + + /// + /// Configuration options for ReDoc UI + /// + public ReDocConfig Config { get; set; } = new ReDocConfig(); + + /// + /// Gets or sets a Stream function for retrieving the redoc page + /// + public Func IndexStream { get; set; } = () => typeof(ReDocOptions).GetTypeInfo().Assembly + .GetManifestResourceStream("Tingle.AspNetCore.Swagger.ReDoc.index.html")!; +} diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocServiceCollectionExtensions.cs b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocServiceCollectionExtensions.cs new file mode 100644 index 0000000..0c26d0a --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/ReDocServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Tingle.AspNetCore.Swagger.ReDoc; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for +/// +public static class ReDocServiceCollectionExtensions +{ + /// + /// Configure ReDoc services + /// + /// the services to be added to + /// The action used to configure the options + /// + public static IServiceCollection AddReDoc(this IServiceCollection services, Action? setupAction = null) + { + if (setupAction is not null) services.Configure(setupAction); + services.ConfigureOptions(); + return services; + } +} diff --git a/src/Tingle.AspNetCore.Swagger/ReDoc/index.html b/src/Tingle.AspNetCore.Swagger/ReDoc/index.html new file mode 100644 index 0000000..67e1a19 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/ReDoc/index.html @@ -0,0 +1,28 @@ + + + + %(DocumentTitle) + + + + + + + + %(HeadContent) + + +
+ + + + \ No newline at end of file diff --git a/src/Tingle.AspNetCore.Swagger/SwaggerJsonSerializerContext.cs b/src/Tingle.AspNetCore.Swagger/SwaggerJsonSerializerContext.cs new file mode 100644 index 0000000..ef9a321 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/SwaggerJsonSerializerContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; +using Tingle.AspNetCore.Swagger.ReDoc; + +namespace Tingle.AspNetCore.Swagger; + +[JsonSerializable(typeof(ReDocConfig))] +internal partial class SwaggerJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.AspNetCore.Swagger/Tingle.AspNetCore.Swagger.csproj b/src/Tingle.AspNetCore.Swagger/Tingle.AspNetCore.Swagger.csproj new file mode 100644 index 0000000..849af2f --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/Tingle.AspNetCore.Swagger.csproj @@ -0,0 +1,27 @@ + + + + Usability extensions for Swagger middleware including smaller ReDoc support + net8.0 + false + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tingle.AspNetCore.Swagger/XmlCommentsHelper.cs b/src/Tingle.AspNetCore.Swagger/XmlCommentsHelper.cs new file mode 100644 index 0000000..d2d3686 --- /dev/null +++ b/src/Tingle.AspNetCore.Swagger/XmlCommentsHelper.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Tingle.AspNetCore.Swagger; + +/// +/// Functionality for working with XML comments in Swagger. +/// +public static partial class XmlCommentsHelper +{ + // TODO: Remove this and related classes once main library supports + // https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/2392 + + private static readonly Regex SeeHrefPattern = GetSeeHrefPattern(); + private static readonly Regex BrPattern = GetBrPattern(); + + /// + [return: NotNullIfNotNull(nameof(text))] + public static string? ToMarkdown(string? text) + { + if (text is null) return null; + + return text.ConvertSeeHrefTags() + .ConvertBrTags(); + } + + private static string ConvertSeeHrefTags(this string text) + { + return SeeHrefPattern.Replace(text, m => $"[{m.Groups[2].Value}]({m.Groups[1].Value})"); + } + + private static string ConvertBrTags(this string text) + { + return BrPattern.Replace(text, m => Environment.NewLine); + } + + [GeneratedRegex(@"(.*)<\/see>")] + private static partial Regex GetSeeHrefPattern(); + [GeneratedRegex(@"(
||
)")] + private static partial Regex GetBrPattern(); +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 75f80ac..025bacf 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -19,4 +19,10 @@ + + + + + + diff --git a/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj b/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj index 605ef7d..d70d02c 100644 --- a/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj +++ b/tests/Tingle.AspNetCore.Authentication.Tests/Tingle.AspNetCore.Authentication.Tests.csproj @@ -1,9 +1,6 @@ - - - diff --git a/tests/Tingle.AspNetCore.Swagger.Tests/FilterCreationTests.cs b/tests/Tingle.AspNetCore.Swagger.Tests/FilterCreationTests.cs new file mode 100644 index 0000000..0fdd4db --- /dev/null +++ b/tests/Tingle.AspNetCore.Swagger.Tests/FilterCreationTests.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; +using Tingle.AspNetCore.Swagger.Filters.Documents; +using Tingle.AspNetCore.Swagger.Filters.Operations; +using Tingle.AspNetCore.Swagger.Filters.Parameters; +using Tingle.AspNetCore.Swagger.Filters.RequestBodies; +using Tingle.AspNetCore.Swagger.Filters.Schemas; + +namespace Tingle.AspNetCore.Swagger.Tests; + +public class FilterCreationTests +{ + [Fact] + public void InheritDocSchemaFilter_IsAdded() + { + var services = new ServiceCollection().AddLogging() + .AddSwaggerGen(o => o.IncludeXmlCommentsFromInheritDocs()) + .BuildServiceProvider(); + + var options = services.GetRequiredService>().Value; + var descriptors = options.SchemaFilterDescriptors.Where(sfd => sfd.Type == typeof(InheritDocSchemaFilter)); + var descriptor = Assert.Single(descriptors); + var arguments = descriptor.Arguments; + Assert.Equal(2, arguments.Length); + Assert.IsType(arguments[0]); + Assert.IsType(arguments[1]); + } + + [Fact] + public void InheritDocSchemaFilter_CanBe_Created() + { + var services = new ServiceCollection().AddLogging() + .AddSwaggerGen(o => o.IncludeXmlCommentsFromInheritDocs()) + .BuildServiceProvider(); + + var options = services.GetRequiredService>().Value; + Assert.Single(options.SchemaFilters.OfType()); + } + + [Fact] + public void MarkdownFilters_AreAdded() + { + var env = new FakeWebHostEnvironment { ApplicationName = "Test", ContentRootPath = Environment.CurrentDirectory, }; + var services = new ServiceCollection().AddLogging() + .AddSwaggerGen(o => o.IncludeXmlComments(env, includeControllerXmlComments: true)) + .AddSwaggerXmlToMarkdown() + .BuildServiceProvider(); + + var options = services.GetRequiredService>().Value; + + Assert.Single(options.DocumentFilterDescriptors.Where(d => d.Type == typeof(MarkdownDocumentFilter))); + Assert.Single(options.OperationFilterDescriptors.Where(d => d.Type == typeof(MarkdownOperationFilter))); + Assert.Single(options.ParameterFilterDescriptors.Where(d => d.Type == typeof(MarkdownParameterFilter))); + Assert.Single(options.RequestBodyFilterDescriptors.Where(d => d.Type == typeof(MarkdownRequestBodyFilter))); + Assert.Single(options.SchemaFilterDescriptors.Where(d => d.Type == typeof(MarkdownSchemaFilter))); + } + + [Fact] + public void MarkdownFilters_CanBe_Created() + { + var env = new FakeWebHostEnvironment { ApplicationName = "Test", ContentRootPath = Environment.CurrentDirectory, }; + var services = new ServiceCollection().AddLogging() + .AddSwaggerGen(o => o.IncludeXmlComments(env, includeControllerXmlComments: true)) + .AddSwaggerXmlToMarkdown() + .AddSingleton(env) + .BuildServiceProvider(); + + var optionsSchema = services.GetRequiredService>().Value; + Assert.Single(optionsSchema.SchemaFilters.OfType()); + + var options = services.GetRequiredService>().Value; + Assert.Single(options.DocumentFilters.OfType()); + Assert.Single(options.OperationFilters.OfType()); + Assert.Single(options.ParameterFilters.OfType()); + Assert.Single(options.RequestBodyFilters.OfType()); + } + + [Fact] + public void EnumDescriptionsFilters_AreAdded() + { + var env = new FakeWebHostEnvironment { ApplicationName = "Test", ContentRootPath = Environment.CurrentDirectory, }; + var services = new ServiceCollection().AddLogging() + .AddSwaggerGen(o => o.IncludeXmlComments(env, includeControllerXmlComments: true)) + .AddSwaggerEnumDescriptions() + .BuildServiceProvider(); + + var options = services.GetRequiredService>().Value; + + Assert.Single(options.SchemaFilterDescriptors.Where(d => d.Type == typeof(EnumDescriptionsSchemaFilter))); + } + + [Fact] + public void EnumDescriptionsFilter_CanBe_Created() + { + var env = new FakeWebHostEnvironment { ApplicationName = "Test", ContentRootPath = Environment.CurrentDirectory, }; + var services = new ServiceCollection().AddLogging() + .AddSwaggerGen(o => o.IncludeXmlComments(env, includeControllerXmlComments: true)) + .AddSwaggerEnumDescriptions() + .BuildServiceProvider(); + + var options = services.GetRequiredService>().Value; + Assert.Single(options.SchemaFilters.OfType()); + } + + private class FakeWebHostEnvironment : IWebHostEnvironment + { + public string WebRootPath { get; set; } = default!; + public IFileProvider WebRootFileProvider { get; set; } = default!; + public string ApplicationName { get; set; } = default!; + public IFileProvider ContentRootFileProvider { get; set; } = default!; + public string ContentRootPath { get; set; } = default!; + public string EnvironmentName { get; set; } = default!; + } +} diff --git a/tests/Tingle.AspNetCore.Swagger.Tests/ReDocEndpointRouteBuilderExtensionsTest.cs b/tests/Tingle.AspNetCore.Swagger.Tests/ReDocEndpointRouteBuilderExtensionsTest.cs new file mode 100644 index 0000000..d4d0abe --- /dev/null +++ b/tests/Tingle.AspNetCore.Swagger.Tests/ReDocEndpointRouteBuilderExtensionsTest.cs @@ -0,0 +1,225 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using System.Net; + +namespace Tingle.AspNetCore.Swagger.Tests; + +public class ReDocEndpointRouteBuilderExtensionsTest +{ + [Fact] + public void ThrowFriendlyErrorForWrongPathFormat() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc("/docs/{documentNam}"); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + services.AddReDoc(); + }); + + var ex = Assert.Throws(() => new TestServer(builder)); + + Assert.Equal( + "The pattern must contain '{documentName}' parameter." + + "Try something similar to '/docs/{documentName=v1}' (Parameter 'pattern')", + ex.Message); + } + + [Fact] // Matches based on '.Map' + public async Task IgnoresRequestThatDoesNotMatchPath() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc("/docs/{documentName=v1}"); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + services.AddReDoc(); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/frob"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] // Matches based on '.Map' + public async Task MatchIsCaseInsensitive() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc(); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + services.AddReDoc(); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/DOCS/v1"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.ToString()); + Assert.NotEqual(0, response.Content.Headers.ContentLength); + } + + [Fact] // Matches based on '.Map' + public async Task DefaultPathIsUsed() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc("/docs/{documentName=v1}"); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + services.AddReDoc(); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/DOCS/v1"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.ToString()); + Assert.NotEqual(0, response.Content.Headers.ContentLength); + } + + [Fact] // Matches based on '.Map' + public async Task DefaultDocumentNameIsUsed() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc("/docs/{documentName=v2}"); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/docs"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.ToString()); + Assert.Contains("/swagger/v2/swagger.json", await response.Content.ReadAsStringAsync()); + } + + [Fact] // Matches based on '.Map' + public async Task DefaultDocumentNameIsNotUsed() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc("/docs/{documentName=v2}"); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.GetAsync("/docs/v1"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.ToString()); + Assert.NotEqual(0, response.Content.Headers.ContentLength); + Assert.Contains("/swagger/v1/swagger.json", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StatusCodeIs404IfNotGet() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc(); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + services.AddReDoc(); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + var response = await client.DeleteAsync("/docs/v1"); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + + response = await client.PostAsync("/docs/v1", new StringContent("")); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + } + + [Fact] + public async Task MapReDoc_ReturnsOk() + { + // Arrange + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapReDoc(); + }); + }) + .ConfigureServices(services => + { + services.AddRouting(); + services.AddReDoc(options => + { + options.Config.ShowExtensions = new List { "x-cake" }; + }); + }); + using var server = new TestServer(builder); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("/docs/v1"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.ToString()); + Assert.NotEqual(0, response.Content.Headers.ContentLength); + } +} diff --git a/tests/Tingle.AspNetCore.Swagger.Tests/Tingle.AspNetCore.Swagger.Tests.csproj b/tests/Tingle.AspNetCore.Swagger.Tests/Tingle.AspNetCore.Swagger.Tests.csproj new file mode 100644 index 0000000..dcf6c82 --- /dev/null +++ b/tests/Tingle.AspNetCore.Swagger.Tests/Tingle.AspNetCore.Swagger.Tests.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/tests/Tingle.AspNetCore.Swagger.Tests/TypeInfoExtensionsTests.cs b/tests/Tingle.AspNetCore.Swagger.Tests/TypeInfoExtensionsTests.cs new file mode 100644 index 0000000..3b2cf51 --- /dev/null +++ b/tests/Tingle.AspNetCore.Swagger.Tests/TypeInfoExtensionsTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; + +namespace Tingle.AspNetCore.Swagger.Tests; + +public class TypeInfoExtensionsTests +{ + [Fact] + public void GetAllFields_Works() + { + var fields = typeof(TestDerived).GetTypeInfo().GetAllFields(); + + Assert.Contains(fields, f => f.Name == "FooField"); + Assert.Contains(fields, f => f.Name == "BarField"); + } + + [Fact] + public void GetAllProperties_Works() + { + var properties = typeof(TestDerived).GetTypeInfo().GetAllProperties(); + + Assert.Contains(properties, p => p.Name == "FooProp"); + Assert.Contains(properties, p => p.Name == "BarProp"); + } + + [Fact] + public void GetAllMembers_Works() + { + var members = typeof(TestDerived).GetTypeInfo().GetAllMembers(); + + Assert.Contains(members, p => p.Name == "FooProp"); + Assert.Contains(members, p => p.Name == "BarProp"); + } + + public class TestBase + { + public string? FooField; + + public int FooProp { get; set; } + } + + public class TestDerived : TestBase + { + public string? BarField; + + public int BarProp { get; set; } + } +} diff --git a/tests/Tingle.AspNetCore.Swagger.Tests/XmlCommentsHelperTests.cs b/tests/Tingle.AspNetCore.Swagger.Tests/XmlCommentsHelperTests.cs new file mode 100644 index 0000000..b74f786 --- /dev/null +++ b/tests/Tingle.AspNetCore.Swagger.Tests/XmlCommentsHelperTests.cs @@ -0,0 +1,34 @@ +namespace Tingle.AspNetCore.Swagger.Tests; + +public class XmlCommentsHelperTests +{ + [Theory] + [InlineData(@"Check Wikipedia for more.", "Check [Wikipedia](https://wikipedia.org) for more.")] + [InlineData(@"ISO currency code", "[ISO currency code](https://www.iso.org/iso-4217-currency-codes.html)")] + public void ToMarkdown_Converts_SeeHref(string input, string expected) + { + var actual = XmlCommentsHelper.ToMarkdown(input); + Assert.Equal(expected, actual, false, true); + } + + [Theory] + [InlineData("
", "\r\n")] + [InlineData("
", "\r\n")] + [InlineData("
", "\r\n")] + public void ToMarkdown_Converts_Br(string input, string expected) + { + var actual = XmlCommentsHelper.ToMarkdown(input); + Assert.Equal(expected, actual, false, true); + } + + [Fact] + public void ToMarkdown_Converts_All() + { + var input = @"Check Wikipedia for more." + + "
"; + var expected = "Check [Wikipedia](https://wikipedia.org) for more." + + "\r\n"; + var actual = XmlCommentsHelper.ToMarkdown(input); + Assert.Equal(expected, actual, false, true); + } +}