From f6cdb9cb2593e811726e5a55541caec8474a6762 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 8 Mar 2026 18:43:46 -0700 Subject: [PATCH 1/5] Minor code clean up --- .../NamespaceParser.cs | 6 +-- .../IApiVersioningBuilderExtensions.cs | 6 +-- .../Asp.Versioning.OpenApi.Tests.csproj | 4 +- .../AssumeCultureAttribute.cs | 39 ------------------- .../Transformers/AcceptanceTest.cs | 15 +++---- 5 files changed, 11 insertions(+), 59 deletions(-) delete mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs diff --git a/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs b/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs index 17a4723f..b6192e02 100644 --- a/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs +++ b/src/Abstractions/src/Asp.Versioning.Abstractions/NamespaceParser.cs @@ -18,9 +18,9 @@ namespace Asp.Versioning; /// Represents API version parser from a type namespace. /// /// -/// The namespace identifier can use 'v', 'V', or '_' as a prefix. The '_' prefix is useful when -/// a folder starts with a number because Visual Studio automatically prefixes it with an underscore. -/// For example, Contoso.Api._2018_04_01.Controllers is equivalent to Contoso.Api.v2018_04_01.Controllers. +/// The namespace identifier can use 'v', 'V', or '_' as a prefix. The '_' prefix is useful +/// when the source folder starts with a number and the editor automatically prefixes it with an underscore. As an +/// example, Api._2018_04_01.Controllers is equivalent to Api.v2018_04_01.Controllers. /// public class NamespaceParser { diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs index c31945a5..b1509b47 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -58,12 +58,8 @@ private static void AddApiExplorerServices( IApiVersioningBuilder builder ) var services = builder.Services; - // registers DefaultApiDescriptionProvider, which discovers controller-based endpoints - services.AddMvcCore().AddApiExplorer(); - - // registers EndpointMetadataApiDescriptionProvider, which discovers minimal API endpoints. - // both providers are required so that OpenApiDocumentService sees all endpoints. services.AddEndpointsApiExplorer(); + services.AddMvcCore().AddApiExplorer(); services.TryAddSingleton, ApiExplorerOptionsFactory>(); services.TryAddTransient(); services.TryAddSingleton( static sp => sp.GetRequiredService().Create() ); diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj index 96d297f4..15db1f9d 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj @@ -13,7 +13,7 @@ - + @@ -35,7 +35,7 @@ - + diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs deleted file mode 100644 index 395a9550..00000000 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/AssumeCultureAttribute.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. - -namespace Asp.Versioning; - -using System.Globalization; -using System.Reflection; -using static System.AttributeTargets; -using static System.Threading.Thread; - -/// -/// Allows a test method to assume that it is running in a specific locale. -/// -[AttributeUsage( Class | Method, AllowMultiple = false, Inherited = true )] -internal sealed class AssumeCultureAttribute : BeforeAfterTestAttribute -{ - private CultureInfo originalCulture; - private CultureInfo originalUICulture; - - public AssumeCultureAttribute( string name ) => Name = name; - - public string Name { get; } - - public override void Before( MethodInfo methodUnderTest, IXunitTest test ) - { - originalCulture = CurrentThread.CurrentCulture; - originalUICulture = CurrentThread.CurrentUICulture; - - var culture = CultureInfo.CreateSpecificCulture( Name ); - - CurrentThread.CurrentCulture = culture; - CurrentThread.CurrentUICulture = culture; - } - - public override void After( MethodInfo methodUnderTest, IXunitTest test ) - { - CurrentThread.CurrentCulture = originalCulture; - CurrentThread.CurrentUICulture = originalUICulture; - } -} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs index 2e8652cc..f44a7f1b 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/AcceptanceTest.cs @@ -13,13 +13,6 @@ namespace Asp.Versioning.OpenApi.Transformers; public class AcceptanceTest { - /// - /// Verifies that minimal API endpoints produce a non-empty OpenAPI document. - /// AddApiExplorer internally calls AddMvcCore().AddApiExplorer(), - /// which auto-discovers controllers from the test assembly. Application parts - /// are cleared to isolate the test to minimal API endpoints only. - /// - /// A representing the asynchronous unit test. [Fact] [AssumeCulture( "en-US" )] public async Task minimal_api_should_generate_expected_open_api_document() @@ -31,9 +24,8 @@ public async Task minimal_api_should_generate_expected_open_api_document() builder.Services.AddApiVersioning( options => AddPolicies( options ) ) .AddApiExplorer( options => options.GroupNameFormat = "'v'VVV" ) .AddOpenApi(); - builder.Services.AddMvcCore() - .ConfigureApplicationPartManager( - m => m.ApplicationParts.Clear() ); + + IsolateMinimalApis( builder.Services ); var app = builder.Build(); var api = app.NewVersionedApi( "Test" ) @@ -133,6 +125,9 @@ public async Task mixed_api_should_generate_expected_open_api_document() JsonNode.DeepEquals( actual, expected ).Should().BeTrue(); } + private static void IsolateMinimalApis( IServiceCollection services ) => + services.AddMvcCore().ConfigureApplicationPartManager( m => m.ApplicationParts.Clear() ); + private static ApiVersioningOptions AddPolicies( ApiVersioningOptions options ) { options.Policies.Deprecate( 1.0 ) From 7a00fe48e49556a1afe862c2bff78144e96012ad Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Sun, 8 Mar 2026 18:46:54 -0700 Subject: [PATCH 2/5] Append instead of change the ".xml" file extension. Fixes #1169 --- .../Transformers/XmlCommentsFile.cs | 2 +- .../Content/v1-minimal.json | 10 +++++----- .../Content/v1-mixed.json | 18 +++++++++--------- .../Content/v1.json | 8 ++++---- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsFile.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsFile.cs index 375deaf2..8a8393f6 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsFile.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsFile.cs @@ -24,7 +24,7 @@ public XmlCommentsFile( Assembly[] assemblies, IHostEnvironment environment ) for ( var i = 0; i < assemblies.Length; i++ ) { var assembly = assemblies[i]; - var fileName = FilePath.ChangeExtension( assembly.GetName().Name, ".xml" ); + var fileName = assembly.GetName().Name + ".xml"; if ( string.IsNullOrEmpty( fileName ) ) { diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json index 3a29e0a2..47ea2612 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json @@ -16,13 +16,13 @@ "tags": [ "Test" ], - "summary": "", - "description": "", + "summary": "Test", + "description": "A test API.", "parameters": [ { "name": "id", "in": "path", - "description": "", + "description": "A test parameter.", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", @@ -43,7 +43,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Pass", "content": { "application/json": { "schema": { @@ -58,7 +58,7 @@ } }, "400": { - "description": "Bad Request" + "description": "Fail" } } } diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json index 30b4afc6..6131de72 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json @@ -16,13 +16,13 @@ "tags": [ "Test" ], - "summary": "", - "description": "", + "summary": "Test", + "description": "A test API.", "parameters": [ { "name": "id", "in": "path", - "description": "", + "description": "A test parameter.", "required": true, "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", @@ -43,7 +43,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Pass", "content": { "application/json": { "schema": { @@ -58,7 +58,7 @@ } }, "400": { - "description": "Bad Request" + "description": "Fail" } } } @@ -68,13 +68,13 @@ "tags": [ "Test" ], - "summary": "", - "description": "", + "summary": "Test", + "description": "A test API.", "parameters": [ { "name": "id", "in": "query", - "description": "", + "description": "A test parameter.", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ @@ -97,7 +97,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Pass", "content": { "text/plain": { "schema": { diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json index 6cb8098e..376bbd95 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json @@ -16,13 +16,13 @@ "tags": [ "Test" ], - "summary": "", - "description": "", + "summary": "Test", + "description": "A test API.", "parameters": [ { "name": "id", "in": "query", - "description": "", + "description": "A test parameter.", "schema": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ @@ -45,7 +45,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Pass", "content": { "text/plain": { "schema": { From b72a54eaad6f2e373bcf49fe430e54bbd9f0f563 Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 12 Mar 2026 04:48:43 -0700 Subject: [PATCH 3/5] Fix file generation --- .../Asp.Versioning.OpenApi.Tests.csproj | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj index 15db1f9d..1712ddd6 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Asp.Versioning.OpenApi.Tests.csproj @@ -13,14 +13,15 @@ + - + - + @@ -32,10 +33,15 @@ + + true + false + + - + From ef24fd053c62f808f01f01bbb4ad8ca8dae653ee Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 12 Mar 2026 05:56:14 -0700 Subject: [PATCH 4/5] Support tags. Fixes #1170 --- .../Asp.Versioning.OpenApi.csproj | 1 + .../Transformers/XmlComments.cs | 62 +++++++++++- .../Transformers/XmlCommentsTransformer.cs | 98 ++++++++++++++++--- .../Content/v1-minimal.json | 3 +- .../Content/v1-mixed.json | 6 +- .../Content/v1.json | 3 +- .../Simulators/MinimalApi.cs | 2 +- .../Simulators/Model.cs | 22 +++++ .../Simulators/TestController.cs | 2 +- .../Simulators/User.cs | 23 +++++ .../Transformers/XmlCommentsTest.cs | 58 +++++++++++ 11 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Model.cs create mode 100644 src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/User.cs diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj index 1df0ce32..b2ad49ce 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Asp.Versioning.OpenApi.csproj @@ -16,6 +16,7 @@ + diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs index d25a2703..59a5c710 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlComments.cs @@ -42,7 +42,7 @@ public class XmlComments /// Gets the summary from the specified member, if any. /// /// The member to get the summary from. - /// The corresponding summary or an empty string. + /// The corresponding <summary> or an empty string. public string GetSummary( MemberInfo member ) => GetMember( member )?.Element( "summary" )?.Value.Trim() ?? string.Empty; @@ -50,7 +50,7 @@ public string GetSummary( MemberInfo member ) /// Gets the description from the specified member, if any. /// /// The member to get the description from. - /// The corresponding description or an empty string. + /// The corresponding <description> or an empty string. public string GetDescription( MemberInfo member ) => GetMember( member )?.Element( "description" )?.Value.Trim() ?? string.Empty; @@ -58,7 +58,7 @@ public string GetDescription( MemberInfo member ) /// Gets the remarks from the specified member, if any. /// /// The member to get the remarks from. - /// The corresponding remarks or an empty string. + /// The corresponding <remarks> or an empty string. public string GetRemarks( MemberInfo member ) => GetMember( member )?.Element( "remarks" )?.Value.Trim() ?? string.Empty; @@ -66,16 +66,24 @@ public string GetRemarks( MemberInfo member ) /// Gets the returns from the specified member, if any. /// /// The member to get the returns from. - /// The corresponding returns or an empty string. + /// The corresponding <returns> or an empty string. public string GetReturns( MemberInfo member ) => GetMember( member )?.Element( "returns" )?.Value.Trim() ?? string.Empty; + /// + /// Gets the example from the specified member, if any. + /// + /// The member to get the example from. + /// The corresponding <example> or an empty string. + public string GetExample( MemberInfo member ) + => GetMember( member )?.Element( "example" )?.Value.Trim() ?? string.Empty; + /// /// Gets the param description from the specified member, if any. /// /// The member to get the parameter from. /// The name of the parameter. - /// The corresponding returns or an empty string. + /// The corresponding description or an empty string. public string GetParameterDescription( MemberInfo member, string name ) { if ( GetMember( member ) is { } element ) @@ -89,6 +97,50 @@ public string GetParameterDescription( MemberInfo member, string name ) return string.Empty; } + /// + /// Gets the parameter example from the specified member, if any. + /// + /// The member to get the parameter from. + /// The name of the parameter. + /// The corresponding <example> or an empty string. + public string GetParameterExample( MemberInfo member, string name ) + { + if ( GetMember( member ) is { } element ) + { + return element.Elements( "param" ) + .FirstOrDefault( x => x.Attribute( "name" )?.Value == name )? + .Attribute( "example" )? + .Value + .Trim() ?? string.Empty; + } + + return string.Empty; + } + + /// + /// Gets the deprecated attribute from the specified member, if any. + /// + /// The member to get the parameter from. + /// The name of the parameter. + /// true if the deprecated attribute is present with a value of "true"; + /// otherwise false. + public bool IsParameterDeprecated( MemberInfo member, string name ) + { + if ( GetMember( member ) is { } element ) + { + var deprecated = element.Elements( "param" ) + .FirstOrDefault( x => x.Attribute( "name" )?.Value == name )? + .Attribute( "deprecated" )?.Value; + + if ( deprecated is { } value ) + { + return StringComparer.OrdinalIgnoreCase.Equals( value, bool.TrueString ); + } + } + + return false; + } + /// /// Gets the response description from the specified member, if any. /// diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs index 664551c6..eab3f198 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/XmlCommentsTransformer.cs @@ -8,6 +8,8 @@ namespace Asp.Versioning.OpenApi.Transformers; using Microsoft.OpenApi; using System; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using static System.Reflection.BindingFlags; @@ -45,24 +47,46 @@ public virtual Task TransformAsync( ArgumentNullException.ThrowIfNull( schema ); ArgumentNullException.ThrowIfNull( context ); - if ( schema.Properties is not { } properties - || context.JsonTypeInfo?.Type is not Type type ) + if ( context.JsonTypeInfo?.Type is not Type type ) { return Task.CompletedTask; } - if ( string.IsNullOrEmpty( schema.Description ) ) + var description = schema.Description; + + if ( string.IsNullOrEmpty( description ) + && !string.IsNullOrEmpty( description = Documentation.GetSummary( type ) ) ) { - schema.Description = Documentation.GetSummary( type ); + schema.Description = description; + } + + if ( schema.Example is null && ToJson( Documentation.GetExample( type ) ) is { } example ) + { + schema.Example = example; + } + + if ( schema.Properties is not { } properties ) + { + return Task.CompletedTask; } foreach ( var (name, prop) in properties ) { if ( prop is not null - && string.IsNullOrEmpty( prop.Description ) - && type.GetProperty( name, IgnoreCase | Instance | Public ) is { } property ) + && type.GetProperty( name, IgnoreCase | Instance | Public ) is { } property ) { - prop.Description = Documentation.GetSummary( property ); + if ( string.IsNullOrEmpty( prop.Description ) + && !string.IsNullOrEmpty( description = Documentation.GetSummary( property ) ) ) + { + prop.Description = description; + } + + if ( prop.Example is null + && prop.Examples is not null + && ( example = ToJson( Documentation.GetExample( property ) ) ) is not null ) + { + prop.Examples.Add( example ); + } } } @@ -88,16 +112,19 @@ public virtual Task TransformAsync( operation.Summary = Documentation.GetSummary( method ); } - if ( string.IsNullOrEmpty( operation.Description ) ) + var description = operation.Description; + + if ( string.IsNullOrEmpty( description ) + && !string.IsNullOrEmpty( description = Documentation.GetDescription( method ) ) ) { - operation.Description = Documentation.GetDescription( method ); + operation.Description = description; } if ( operation.Responses is { } responses ) { foreach ( var (statusCode, response) in responses ) { - var description = Documentation.GetResponseDescription( method, statusCode ); + description = Documentation.GetResponseDescription( method, statusCode ); if ( !string.IsNullOrEmpty( description ) ) { @@ -118,18 +145,40 @@ public virtual Task TransformAsync( { var parameter = parameters[i]; - if ( !string.IsNullOrEmpty( parameter.Name ) && string.IsNullOrEmpty( parameter.Description ) ) + if ( string.IsNullOrEmpty( parameter.Name ) ) { - for ( var j = 0; j < args.Count; j++ ) + continue; + } + + for ( var j = 0; j < args.Count; j++ ) + { + var arg = args[j]; + + if ( arg.Name != parameter.Name ) { - var arg = args[i]; + continue; + } + + var name = arg.ParameterDescriptor.Name; + + if ( string.IsNullOrEmpty( parameter.Description ) + && !string.IsNullOrEmpty( description = Documentation.GetParameterDescription( method, name ) ) ) + { + parameter.Description = description; + } - if ( arg.Name == parameter.Name ) + if ( parameter is OpenApiParameter param ) + { + if ( param.Example is null + && ToJson( Documentation.GetParameterExample( method, name ) ) is { } example ) { - var name = arg.ParameterDescriptor.Name; - parameter.Description = Documentation.GetParameterDescription( method, name ); + param.Example = example; } + + param.Deprecated |= Documentation.IsParameterDeprecated( method, name ); } + + break; } } @@ -159,4 +208,21 @@ private static bool TryResolveMethod( ActionDescriptor action, [MaybeNullWhen( f method = default; return false; } + + private static JsonNode? ToJson( string? example ) + { + if ( string.IsNullOrEmpty( example ) ) + { + return default; + } + + try + { + return JsonNode.Parse( example ); + } + catch ( JsonException ) + { + return JsonNode.Parse( $"\"{example}\"" ); + } + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json index 47ea2612..6f54d265 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json @@ -28,7 +28,8 @@ "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" - } + }, + "example": 42 }, { "name": "api-version", diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json index 6131de72..4dabbd2e 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json @@ -28,7 +28,8 @@ "pattern": "^-?(?:0|[1-9]\\d*)$", "type": "integer", "format": "int32" - } + }, + "example": 42 }, { "name": "api-version", @@ -82,7 +83,8 @@ "string" ], "format": "int32" - } + }, + "example": 42 }, { "name": "api-version", diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json index 376bbd95..94ee32ca 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json @@ -30,7 +30,8 @@ "string" ], "format": "int32" - } + }, + "example": 42 }, { "name": "api-version", diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs index 014dafb0..422cd388 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/MinimalApi.cs @@ -10,7 +10,7 @@ public static class MinimalApi /// Test /// /// A test API. - /// A test parameter. + /// A test parameter. /// The original identifier. /// Pass /// Fail diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Model.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Model.cs new file mode 100644 index 00000000..d096b567 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/Model.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable SA1629 + +namespace Asp.Versioning.OpenApi.Simulators; + +/// +/// Represents a model. +/// +public class Model +{ + /// + /// Gets or sets the user associated with the model. + /// + /// + /// { + /// "userName": "John Doe", + /// "email": "john.doe@example.com" + /// } + /// + public User User { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/TestController.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/TestController.cs index 363012e5..c86fca90 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/TestController.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/TestController.cs @@ -16,7 +16,7 @@ public class TestController : ControllerBase /// Test /// /// A test API. - /// A test parameter. + /// A test parameter. /// The original identifier. /// Pass /// Fail diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/User.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/User.cs new file mode 100644 index 00000000..6594f848 --- /dev/null +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Simulators/User.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +#pragma warning disable SA1629 + +namespace Asp.Versioning.OpenApi.Simulators; + +/// +/// Represents a user. +/// +public class User +{ + /// + /// Gets or sets the username associated with the account. + /// + /// John Doe + public string UserName { get; set; } + + /// + /// Gets or sets the email address associated with the user. + /// + /// user@example.com + public string Email { get; set; } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs index c0ae2c71..c7b6374d 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Transformers/XmlCommentsTest.cs @@ -3,6 +3,7 @@ namespace Asp.Versioning.OpenApi.Transformers; using Asp.Versioning.OpenApi.Simulators; +using System.Text.Json.Nodes; public class XmlCommentsTest { @@ -62,6 +63,20 @@ public void response_description_should_be_retrieved_for_minimal_api() description.Should().Be( "Pass" ); } + [Fact] + public void parameter_example_should_be_retrieved_for_minimal_api() + { + // arrange + var comments = XmlComments.FromFile( FilePath.XmlCommentFile ); + var method = typeof( MinimalApi ).GetMethod( nameof( MinimalApi.Get ) ); + + // act + var example = comments.GetParameterExample( method, "id" ); + + // assert + example.Should().Be( "42" ); + } + [Fact] public void summary_should_be_retrieved_for_controller() { @@ -117,4 +132,47 @@ public void response_description_should_be_retrieved_for_controller() // assert description.Should().Be( "Fail" ); } + + [Fact] + public void example_parameter_should_be_retrieved_for_controller() + { + // arrange + var comments = XmlComments.FromFile( FilePath.XmlCommentFile ); + var method = typeof( TestController ).GetMethod( nameof( TestController.Get ) ); + + // act + var example = comments.GetParameterExample( method, "id" ); + + // assert + example.Should().Be( "42" ); + } + + [Fact] + public void example_property_should_be_retrieved_from_model() + { + // arrange + var comments = XmlComments.FromFile( FilePath.XmlCommentFile ); + var property = typeof( Model ).GetProperty( nameof( Model.User ) ); + var expected = JsonNode.Parse( """{"userName":"John Doe","email":"john.doe@example.com"}""" ); + + // act + var actual = JsonNode.Parse( comments.GetExample( property ) ); + + // assert + JsonNode.DeepEquals( expected, actual ).Should().BeTrue(); + } + + [Fact] + public void example_property_should_be_retrieved_from_nested_model() + { + // arrange + var comments = XmlComments.FromFile( FilePath.XmlCommentFile ); + var property = typeof( User ).GetProperty( nameof( User.Email ) ); + + // act + var example = comments.GetExample( property ); + + // assert + example.Should().Be( "user@example.com" ); + } } \ No newline at end of file From ae170e45619297920e2f4710b0720b410a82a5cb Mon Sep 17 00:00:00 2001 From: Chris Martinez Date: Thu, 12 Mar 2026 05:57:15 -0700 Subject: [PATCH 5/5] Refactor OpenAPI extensions as "links" nested property instead of array to allow future extensions --- .../Transformers/ApiExplorerTransformer.cs | 7 +++-- .../Content/v1-minimal.json | 30 ++++++++++--------- .../Content/v1-mixed.json | 30 ++++++++++--------- .../Content/v1.json | 30 ++++++++++--------- 4 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs index 84b1d7d3..905fe4c9 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.OpenApi/Transformers/ApiExplorerTransformer.cs @@ -301,8 +301,11 @@ private void AddLinkExtensions( OpenApiDocument document, ApiVersionDescription if ( array.Count > 0 ) { + var obj = new JsonObject(); var extensions = document.Extensions ??= new Dictionary(); - extensions[ExtensionName] = new JsonNodeExtension( array ); + + obj["links"] = array; + extensions[ExtensionName] = new JsonNodeExtension( obj ); } } @@ -347,7 +350,7 @@ protected virtual JsonObject ToJson( LinkHeaderValue link ) if ( link.Languages.Count > 0 ) { - obj["lang"] = new JsonArray( link.Languages.Select( l => JsonNode.Parse( l.ToString() ) ).ToArray() ); + obj["lang"] = new JsonArray( [.. link.Languages.Select( l => JsonNode.Parse( l.ToString() ) )] ); } foreach ( var (key, value) in link.Extensions ) diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json index 6f54d265..8fb590a9 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-minimal.json @@ -70,18 +70,20 @@ "name": "Test" } ], - "x-api-versioning": [ - { - "title": "Version Deprecation Policy", - "type": "text/html", - "rel": "deprecation", - "url": "http://my.api.com/policies/versions/deprecated.html" - }, - { - "title": "Version Sunset Policy", - "type": "text/html", - "rel": "sunset", - "url": "http://my.api.com/policies/versions/sunset.html" - } - ] + "x-api-versioning": { + "links": [ + { + "title": "Version Deprecation Policy", + "type": "text/html", + "rel": "deprecation", + "url": "http://my.api.com/policies/versions/deprecated.html" + }, + { + "title": "Version Sunset Policy", + "type": "text/html", + "rel": "sunset", + "url": "http://my.api.com/policies/versions/sunset.html" + } + ] + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json index 4dabbd2e..d352c3d1 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1-mixed.json @@ -142,18 +142,20 @@ "name": "Test" } ], - "x-api-versioning": [ - { - "title": "Version Deprecation Policy", - "type": "text/html", - "rel": "deprecation", - "url": "http://my.api.com/policies/versions/deprecated.html" - }, - { - "title": "Version Sunset Policy", - "type": "text/html", - "rel": "sunset", - "url": "http://my.api.com/policies/versions/sunset.html" - } - ] + "x-api-versioning": { + "links": [ + { + "title": "Version Deprecation Policy", + "type": "text/html", + "rel": "deprecation", + "url": "http://my.api.com/policies/versions/deprecated.html" + }, + { + "title": "Version Sunset Policy", + "type": "text/html", + "rel": "sunset", + "url": "http://my.api.com/policies/versions/sunset.html" + } + ] + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json index 94ee32ca..037036f4 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.OpenApi.Tests/Content/v1.json @@ -89,18 +89,20 @@ "name": "Test" } ], - "x-api-versioning": [ - { - "title": "Version Deprecation Policy", - "type": "text/html", - "rel": "deprecation", - "url": "http://my.api.com/policies/versions/deprecated.html" - }, - { - "title": "Version Sunset Policy", - "type": "text/html", - "rel": "sunset", - "url": "http://my.api.com/policies/versions/sunset.html" - } - ] + "x-api-versioning": { + "links": [ + { + "title": "Version Deprecation Policy", + "type": "text/html", + "rel": "deprecation", + "url": "http://my.api.com/policies/versions/deprecated.html" + }, + { + "title": "Version Sunset Policy", + "type": "text/html", + "rel": "sunset", + "url": "http://my.api.com/policies/versions/sunset.html" + } + ] + } } \ No newline at end of file