domaindrivendev / Swashbuckle.AspNetCore

Swagger tools for documenting API's built on ASP.NET Core
MIT License
5.25k stars 1.31k forks source link

[Bug]: Query string parameter example repeats the example in the description and in the value #3089

Open alekdavis opened 3 weeks ago

alekdavis commented 3 weeks ago

Describe the bug

If I define a controller parameter as a query string value (e.g. using the FromQuery attribute) and specify the example attribute of the param element in the XML documentation, the specified example will be displayed in two places: in the text description of the parameter (Example : somevalue) and in the HTML element (in the actual field).

Expected behavior

I think just setting the value of the element should be sufficient, so there is no need to provide additional Example text.

Actual behavior

The example values is pre-populated in the field element and also in the text description

Steps to reproduce

Define a controller query string parameter and document it in XML with the example attribute, similar to the following (removed all non-essential elements):

    /// <summary>
    /// Gets the list of workgroups matching the specified criteria.
    /// </summary>
    /// <param name="filter" example="type eq 'Approver' and name eq 'NameOfTheWorkgroup'">
    /// Defines the search criteria in the
    /// <a href="https://docs.microsoft.com/en-us/graph/query-parameters#odata-system-query-options" title="OData system query options" target="_blank">OData format</a>.
    /// </param>
    /// <param name="top">
    /// Maximum number of records to be returned (zero means the default maximum).
    /// </param>
    /// <returns>
    /// List of workgroups matching the specified <paramref name="filter"/> criteria.
    /// </returns>
    [Route("workgroups", Name = "get-workgroup-list-by-filter")]
    [HttpGet]
    public ActionResult<IList<Workgroup>> GetWorkgroupListByFilter
    (
        [FromQuery(Name = "$filter")] string filter,
        [FromQuery(Name = "$top")] int top = 0
    )
    {
        ...
    }

The Swagger documentation will show the example in two places:

image

Exception(s) (if any)

No response

Swashbuckle.AspNetCore version

6.8.1

.NET Version

8

Anything else?

Not sure if this info is useful, but for path parameters, ther example values are not duplicated.

jgarciadelanoceda commented 3 weeks ago

Do you have more configuration plugged into SwashBuckle?

I have just added your code into a TestProject that we have and it seems OK: image

alekdavis commented 3 weeks ago

I am using additional packages to address various issues (e.g. using enum names instead of numeric values, etc):

Swashbuckle.AspNetCore {6.8.1} Swashbuckle.AspNetCore.Annotations {6.8.1} Swashbuckle.AspNetCore.Filters {8.0.2} Swashbuckle.AspNetCore.Newtonsoft {6.8.1} Unchase.Swashbuckle.AspNetCore.Extensions {2.7.1}

alekdavis commented 3 weeks ago

Here is my full Swagger setup logic:

public static class ServiceCollectionSwaggerExtensions {

    /// <summary>
    /// Initializes Swagger configuration.
    /// </summary>
    /// <param name="services">
    /// Exposed application services.
    /// </param>
    /// <param name="logger">
    /// Logger.
    /// </param>
    /// <param name="environment">
    /// Used to check the deployment environment and for local debugging so these: 
    /// (1) Add the localhost server URL so you can debug locally
    /// (2) Replace all OAuth flows with implicit so you can use SwaggerUI
    /// </param>
    /// <param name="apiTenantId">
    /// ID of the Azure tenant hosting the API (OAuth provider).
    /// </param>
    /// <param name="apiClientId">
    /// ID of the API's service principal in Azure.
    /// </param>
    /// <param name="exampleTypes">
    /// Data types from assemblies that need to be included in the Swagger examples.
    /// If not specified, all types implementing the IExamplesProvider or IMultipleExamplesProvider
    /// interface will be included.
    /// </param>
    /// <param name="versionLabel">
    /// The version label of the API, such as 'v1'.
    /// </param>
    /// <param name="title">
    /// The API title (pass <c>null</c> to get it from the assembly Product attribute).
    /// </param>
    /// <param name="version">
    /// The API version (pass <c>null</c> to get it from the assembly Version attribute).
    /// </param>
    /// <param name="description">
    /// The API description (pass <c>null</c> to get it from the assembly Description attribute).
    /// </param>
    /// <param name="contactName">
    /// Name of the contact for the Swagger documentation.
    /// </param>
    /// <param name="contactUrl">
    /// The contact URL for the Swagger documentation.
    /// </param>
    /// <param name="contactEmail">
    /// The contact email for the Swagger documentation.
    /// </param>
    /// <param name="serverUrl">
    /// The server URL to be included in the drop-down box (can be <c>null</c>).
    /// </param>
    /// <param name="scopes">
    /// The list of all supported scopes.
    /// </param>
    public static void ConfigureSwagger
    (
        this IServiceCollection services,
        Serilog.ILogger logger,
        IWebHostEnvironment environment,
        string apiTenantId,
        string apiClientId,
        Type[]? exampleTypes,
        string versionLabel /* v1 */,
        string? title,
        string? version,
        string? description,
        string contactName,
        string contactUrl,
        string contactEmail,
        string? serverUrl,
        string[] scopes
    ) 
    {
        logger.Information("Started Swagger initialization.");

        if (exampleTypes == null || exampleTypes.Length == 0)
        {
            IEnumerable<Type> types = GetExampleTypes();

            if (types != null)
            {
                exampleTypes = types.ToArray();
            }
        }

        if (exampleTypes == null || exampleTypes.Length == 0)
        {
            logger.Information("No Swagger example types found in the running application.");
        }
        else
        {
            logger.Information(
                "Adding examples for types:");
            for (int i = 0; i < exampleTypes.Length; i++)
            {
                logger.Information(
                    "- {exampleType}", exampleTypes[i]);
            }
        }

        _ = services.AddSwaggerExamplesFromAssemblyOf(exampleTypes);

        logger.Information("Generating documentation.");

        _ = services.AddSwaggerGen(
            options =>
            {
                logger.Information("Initializing documentation.");
                options.SwaggerDoc(versionLabel,
                    new OpenApiInfo
                    {
                        Title = string.IsNullOrEmpty(title) ? AssemblyInfo.Product : title,
                        Version = string.IsNullOrEmpty(version) ? AssemblyInfo.Version : version,
                        Description = string.IsNullOrEmpty(description) ? AssemblyInfo.Description : description,
                        Contact = new OpenApiContact()
                        {
                            Name = contactName,
                            Url = new Uri(contactUrl),
                            Email = contactEmail
                        },
                    }
                );

                // Necessary for including annotations from SwaggerResponse attributes in Swagger documentation.
                logger.Information("Enabling annotations.");
                options.EnableAnnotations();

                // See "Add custom serializer to Swagger in my .Net core API":
                // https://stackoverflow.com/questions/59902076/add-custom-serializer-to-swagger-in-my-net-core-api#answer-64812850
                logger.Information("Using example filters.");
                options.ExampleFilters();

                // See "Swagger Swashbuckle Asp.NET Core: show details about every enum is used":
                // https://stackoverflow.com/questions/65312198/swagger-swashbuckle-asp-net-core-show-details-about-every-enum-is-used#answer-65318486
                logger.Information("Using schema filters for enum values.");
                options.SchemaFilter<EnumSchemaFilter>();

                // Add localhost first because that's the default when debugging.
                // See "How Do You Access the `applicationUrl` Property Found in launchSettings.json from Asp.NET Core 3.1 Startup class?":
                // https://stackoverflow.com/questions/59398439/how-do-you-access-the-applicationurl-property-found-in-launchsettings-json-fro#answer-60489767
                if (environment.IsDevelopment())
                {
                    logger.Information("Adding localhost servers:");
                    string[]? localHosts = System.Environment.GetEnvironmentVariable("ASPNETCORE_URLS")?.Split(";");

                    if (localHosts != null && localHosts.Length > 0)
                    {
                        foreach (string localHost in localHosts)
                        {
                            logger.Information($"- {localHost}");
                            options.AddServer(new OpenApiServer() { Url = localHost });
                        }
                    }
                }

                if (!string.IsNullOrEmpty(serverUrl))
                {
                    logger.Information("Adding server:");
                    logger.Information("- {serverUrl}", serverUrl);
                    options.AddServer(new OpenApiServer() { Url = serverUrl });
                }

                Uri? authorizationUrl = null;
                Uri? tokenUrl = null;

                OpenApiOAuthFlow? authorizationCodeFlow = null;
                OpenApiOAuthFlow? clientCredentialsFlow = null;
                OpenApiOAuthFlow? implicitFlow = null;

                if (!string.IsNullOrEmpty(apiTenantId))
                {
                    logger.Information("Setting authorization URL:");
                    authorizationUrl = new Uri($"https://login.microsoftonline.com/{apiTenantId}/oauth2/v2.0/authorize");
                    logger.Information("- {authorizationUrl}", authorizationUrl);

                    logger.Information("Setting token URL:");
                    tokenUrl = new Uri($"https://login.microsoftonline.com/{apiTenantId}/oauth2/v2.0/token");
                    logger.Information("- {tokenUrl}", tokenUrl);
                }

                Dictionary<string, string> userScopes = [];

                if (!string.IsNullOrEmpty(apiClientId))
                {
                    if (scopes != null && scopes.Length > 0)
                    {
                        foreach (string scope in scopes)
                        {
                            string scopeFqn = Scope.ToFullyQualifiedName(apiClientId, scope);

                            if (userScopes.ContainsKey(scopeFqn))
                            {
                                continue;
                            }

                            userScopes.Add(scopeFqn, scope);
                        }
                    }

                    string defaultScope = ".default";
                    string defaultScopeFqn = Scope.ToFullyQualifiedName(apiClientId, defaultScope);

#pragma warning disable CA1864 // Prefer the 'IDictionary.TryAdd(TKey, TValue)' method
                    if (!userScopes.ContainsKey(defaultScopeFqn))
                    {
                        userScopes.Add(defaultScopeFqn, defaultScope);
                    }
#pragma warning restore CA1864 // Prefer the 'IDictionary.TryAdd(TKey, TValue)' method
                }

                Dictionary<string, string> clientScopes = [];

                if (string.IsNullOrEmpty(apiClientId))
                {
                    clientScopes.Add(Scope.ToFullyQualifiedName(apiClientId, ".default"), "All assigned roles");
                }

                if (authorizationUrl != null)
                {
                    logger.Information("Setting up authorization code flow for user scopes.");
                    authorizationCodeFlow = new OpenApiOAuthFlow()
                    {
                        AuthorizationUrl = authorizationUrl,
                        TokenUrl = tokenUrl,
                        Scopes = userScopes
                    };

                    logger.Information("Setting up implicit flow for user scopes.");
                    implicitFlow = new OpenApiOAuthFlow()
                    {
                        AuthorizationUrl = authorizationUrl,
                        Scopes = userScopes
                    };
                }

                if (tokenUrl != null)
                {
                    logger.Information("Setting up client credential flow for client scopes.");
                    clientCredentialsFlow = new OpenApiOAuthFlow()
                    {
                        TokenUrl = tokenUrl,
                        Scopes = clientScopes
                    };
                }

                // OAuth authentication scheme:
                // 'oauth2' is needed for Apigee to work on localhost.
                // 'oauth2ClientCreds' is required by Apigee.
                string securityScheme = environment.IsDevelopment() ?
                    "oauth2" : "oauth2";
                // "oauth2" : "oauth2ClientCreds";

                logger.Information("Adding security definitions for the OAuth flows.");
                options.AddSecurityDefinition(securityScheme, new OpenApiSecurityScheme
                {
                    Type = SecuritySchemeType.OAuth2,
                    Flows = new OpenApiOAuthFlows()
                    {
                        AuthorizationCode = environment.IsDevelopment() ? null : authorizationCodeFlow,
                        ClientCredentials = environment.IsDevelopment() ? null : clientCredentialsFlow,
                        Implicit = environment.IsDevelopment() ? implicitFlow : null
                    }
                });

                logger.Information("Adding OpenAPI security requirement.");
                options.AddSecurityRequirement(

                    // OpenApiSecurityRequirement extends Dictionary.
                    // This code is using hash table initialization.
                    // That's why there are so many curly braces.
                    // We're creating a hash table with only one key/value pair.
                    new OpenApiSecurityRequirement() {
                            {
                                // This is the Dictionary Key
                                new OpenApiSecurityScheme {
                                    Reference = new OpenApiReference {
                                        Type = ReferenceType.SecurityScheme,
                                        Id = "oauth2"
                                    },
                                    Scheme = "oauth2",
                                    Name = "oauth2",
                                    In = ParameterLocation.Header
                                },
                                // This is the dictionary value.
                                new List<string>()
                            }
                });

                // The <inheritdoc/> filter only applies to properties.
                logger.Information("Including XML comments from inherited documents.");
                options.IncludeXmlCommentsFromInheritDocs(includeRemarks: true);

                // Load XML documentation into Swagger.
                // https://github.com/domaindrivendev/Swashbuckle.WebApi/issues/93
                List<string> xmlFiles = [.. Directory.GetFiles(AppContext.BaseDirectory,"*.xml",SearchOption.TopDirectoryOnly)];

                logger.Information("Reading documentation files:");
                if (xmlFiles != null && xmlFiles.Count > 0)
                {
                    xmlFiles.ForEach(xmlFile =>
                    {
                        logger.Information("- {xmlFile}", Path.GetFileName(xmlFile));

                        XDocument xmlDoc = XDocument.Load(xmlFile);

                        options.IncludeXmlComments(() => new XPathDocument(xmlDoc.CreateReader()), true);
                        options.SchemaFilter<DescribeEnumMembers>(xmlDoc);
                    });
                }

                // Apparently, there are bugs in Swashbuckle or Swagger that cause issues with enum handling.
                // Got this workaround from:
                // https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1329#issuecomment-566914371
                logger.Information("Implementing a workaround for the enum type handling bug.");
                foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies())
                {
                    foreach (Type t in a.GetTypes())
                    {
                        if (t.IsEnum)
                        {
                            options.MapType(t, () => new OpenApiSchema
                            {
                                Type = "string",
                                Enum = t.GetEnumNames().Select(
                                    name => new OpenApiString(name)).Cast<IOpenApiAny>().ToList(),
                                Nullable = true
                            });
                        }
                    }
                }

                // Order controllers and endpoints alphabetically. From:
                // https://stackoverflow.com/questions/46339078/how-can-i-change-order-the-operations-are-listed-in-a-group-in-swashbuckle
                logger.Information("Sorting controllers and endpoints.");

                options.OrderActionsBy(
                    (apiDesc) => $"{apiDesc.ActionDescriptor.RouteValues["controller"]}_{apiDesc.RelativePath?.ToLower()}_{apiDesc.HttpMethod?.ToLower()}"
                    );
            }
        );

        logger.Information("Adding Newtonsoft JSON.NET support to Swagger.");

        // https://stackoverflow.com/questions/68337082/swagger-ui-is-not-using-newtonsoft-json-to-serialize-decimal-instead-using-syst
        // https://github.com/domaindrivendev/Swashbuckle.AspNetCore?tab=readme-ov-file#systemtextjson-stj-vs-newtonsoft
        services.AddSwaggerGenNewtonsoftSupport(); // explicit opt-in - needs to be placed after AddSwaggerGen()

        // Fix for controller sort order from https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/2772
        logger.Information("Implementing a fix for the top-level controller sort order.");

        services.Configure<SwaggerUIOptions>(options =>
        {
            // Controller or tag level (top level group in UI) 
            options.ConfigObject.AdditionalItems["tagsSorter"] = "alpha";

            // Within a controller, operations are the endpoints. You may not need this one 
            options.ConfigObject.AdditionalItems["operationsSorter"] = "alpha";
        });

        logger.Information("Completed Swagger initialization.");
    }

    /// <summary>
    /// Returns all types implementing example interfaces loaded by the running application.
    /// </summary>
    /// <returns>
    /// Collection of types.
    /// </returns>
    private static IEnumerable<Type> GetExampleTypes()
    {
        return AppDomain.CurrentDomain.GetAssemblies()
            .Where(a => a.IsDynamic == false)
            .SelectMany(a => a.GetTypes())
            .Where(t => t.GetInterfaces()
            .Any(i => i.IsGenericType &&
                (i.GetGenericTypeDefinition() == typeof(IExamplesProvider<>) ||
                 i.GetGenericTypeDefinition() == typeof(IMultipleExamplesProvider<>))));
    }
}
jgarciadelanoceda commented 3 weeks ago

This behaviour that you are having does not seem to be caused by SwashBuckle, because I just created the same scenaro that you have. Just to make sure that is not comming from SwashBuckle, create a minimal representation of the Api that you are trying to create with the less dependencies possible, and if the issue exist just using SwashBuckle then create a repo so I can see what's happening

alekdavis commented 3 weeks ago

I will give it a try. Thanks for checking. Will report back once I figure it out.

alekdavis commented 3 weeks ago

@martincostello Okay, I just created an ASP.NET Core API with the weather forecast controller example generated by Visual Studio and made the following changes: (1) Commented controller, method, and returned object class, (2) added query string property to the Get controller method, (3) updated project to generate the XML file with the documentation, and added the following code block to the AddSwaggerGen call to include comments from the XML documentation files (I need to read all XML doc files because I have multiple projects dependencies):

builder.Services.AddSwaggerGen
(
    options =>
    {
        List<string> xmlFiles = [.. Directory.GetFiles(AppContext.BaseDirectory,"*.xml",SearchOption.TopDirectoryOnly)];

        if (xmlFiles != null && xmlFiles.Count > 0)
        {
            xmlFiles.ForEach(xmlFile =>
            {
                XDocument xmlDoc = XDocument.Load(xmlFile);
                options.IncludeXmlComments(() => new XPathDocument(xmlDoc.CreateReader()), true);
             });
        }
    }
);

So, my whole Program.cs file look like this:

using System.Xml.Linq;
using System.Xml.XPath;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen
(
    options =>
    {
        List<string> xmlFiles = [.. Directory.GetFiles(AppContext.BaseDirectory,"*.xml",SearchOption.TopDirectoryOnly)];

        if (xmlFiles != null && xmlFiles.Count > 0)
        {
            xmlFiles.ForEach(xmlFile =>
            {
                XDocument xmlDoc = XDocument.Load(xmlFile);
                options.IncludeXmlComments(() => new XPathDocument(xmlDoc.CreateReader()), true);
             });
        }
    }
);

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

And the controller file looks like this:

using Microsoft.AspNetCore.Mvc;

namespace TestSwagger.Controllers;
/// <summary>
/// Weather service.
/// </summary>
[ApiController]
[Route("[controller]")]
public class WeatherForecastController:ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly ILogger<WeatherForecastController> _logger;

    /// <summary>
    /// Weather service.
    /// </summary>
    /// <param name="logger">
    /// Logger.
    /// </param>
    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }

    /// <summary>
    /// Returns weather info for the city.
    /// </summary>
    /// <param name="city" example="Seattle">
    /// City.
    /// </param>
    /// <returns>
    /// Weather info.
    /// </returns>
    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get
    (
        [FromQuery] string city
    )
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

There is only one Nuget package: image

And the result is what I originally reported: image

I can share the whole solution if it helps but maybe this info will give you enough data.

jgarciadelanoceda commented 3 weeks ago

I have just reproduced it.. Not in the repo of SwashBuckle but instead in other repo.. Weird that is not reproduced in the SB repo