Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: Description of a property shows the summary of the underlying class instead of the property summary #3240

Open
alekdavis opened this issue Jan 29, 2025 · 13 comments
Labels
bug help-wanted A change up for grabs for contributions from the community

Comments

@alekdavis
Copy link

Describe the bug

In the Swagger documentation, descriptions of properties derived from classes always show the information from the <summary> of the class, not the <summary> of the property. For example, here is the definition of the slightly altered WeatherForecast demo project's WeatherForecast class:

/// <summary>
/// Weather forecast info.
/// </summary>
public class WeatherForecast
{
    /// <summary>
    ///  City name.
    /// </summary>
    public string? City { get; set; }

    /// <summary>
    /// Forecast date.
    /// </summary>
    public DateOnly Date { get; set; }

    /// <summary>
    /// Forecast summary.
    /// </summary>
    public string? Summary { get; set; }

    /// <summary>
    /// Forecasted temperature in the specified city.
    /// </summary>
    public Temperature? Temperature { get; set; }
}

The Temperature property is a complex type defined as:

/// <summary>
/// Holds temperature in Celsius and Fahrenheit.
/// </summary>
public class Temperature
{
    /// <summary>
    /// Temperature in C.
    /// </summary>
    public int TemperatureC { get; set; }

    /// <summary>
    /// Temperature in F.
    /// </summary>
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Notice that the summary of the Temperature property in the WeatherForecast class (Forecasted temperature in the specified city.) is different from the summary of the Temperature class definition (Holds temperature in Celsius and Fahrenheit.), but when displayed in the Swagger documentation, the summary of the property is discarded, so only the class summary is displayed:

Image

The problem is that the class summary is not specific enough to describe a property derived from this class. It may not be obvious in this example, but say, I have a meta class that defines the details of a patch operation for patching objects and I want to specify which patch operations are supported for each class. This can be only be done in the summary of the property, not in the class summary (because the class does not know where and how it will be used), right? But there seems to be no way to do it.

Attached is the sample project.

Expected behavior

Swagger description of a schema's (class) property must always contain the summary of this property as defined in the containing schema (class), not the summary of the class (schema) that this property is derived from.

Actual behavior

Swagger description of a schema's (class) property always shows the summary of the class (schema) that this property is derived from, not the property's summary.

Steps to reproduce

Use the attached project.

TestSwagger.zip

Exception(s) (if any)

No response

Swashbuckle.AspNetCore version

7.2.0

.NET Version

9.0.102

Anything else?

No response

@alekdavis alekdavis added the bug label Jan 29, 2025
@martincostello martincostello added the help-wanted A change up for grabs for contributions from the community label Jan 30, 2025
@martincostello
Copy link
Collaborator

I have a very vague memory of noticing this before I was a maintainer of Swashbuckle, and digging into it and finding it was something to do with how the internals of Microsoft.OpenApi works, but I could be misremembering.

@alekdavis
Copy link
Author

@martincostello I'm surprise it has not been reported before (at least, I couldn't find any issues with a similar description). Seems like an important omission.

@jgarciadelanoceda
Copy link
Contributor

jgarciadelanoceda commented Jan 30, 2025

@alekdavis it seems that it was reported and fixed see #2379.
I will look into it by the way

@alekdavis
Copy link
Author

@jgarciadelanoceda Ah, sweet, I added a call to UseAllOfToExtendReferenceSchemas(); and it worked:

Image

Thanks a lot. I have not seen this one mentioned anywhere, so didn't know it was needed.

@alekdavis alekdavis reopened this Jan 31, 2025
@alekdavis
Copy link
Author

Ugh, it worked in the sample project, but not in my actual app. Not sure what the difference is. Here is my code snippet:

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

// 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.UseAllOfToExtendReferenceSchemas();
        options.SchemaFilter<DescribeEnumMembers>(xmlDoc);
    });
}

I tried adding the call for each doc file (commented out in the sample), but it didn't help. Not sure what I'm doing wrong.

@jgarciadelanoceda
Copy link
Contributor

Please provide a minimal example, if it's possible a gh repo with the minimal things possible

@alekdavis
Copy link
Author

alekdavis commented Jan 31, 2025

Thanks @jgarciadelanoceda. I am totally puzzled: my minimal app works fine, my actual app does not, but the actual app uses a common library for setting up Swagger and another demo app that uses this library also works fine. There is absolutely no difference in Swagger setting code between the two (except they get different inputs and XML sources). I spent a couple hours and no matter what I tried, I cannot figure why common code in one case has the problem and in another one does not. Since it's an actual app, I cannot share the code, but here are some snippets, maybe they can give some hints.

My complex object summary:

/// <summary>
/// Holds properties and methods that may be needed for specific operations on the object,
/// such as REST PATCH calls.
/// </summary>
public class Meta

XML file for the object docs (common library) produces (with namespace redacted):

<member name="T:XXX.XXX.Common.Metadata.Meta">
<summary> Holds properties and methods that may be needed for specific operations on the object, such as REST PATCH calls. </summary>
</member>

The property summary that uses Meta:

/// <summary>
/// MUST REPLACE THE META SUMMARY.
/// </summary>
[JsonProperty("meta", Order = -100000)]
public virtual Meta? Meta { get; set; }

XML file for the property (it's in the app file) produces (with namespace redacted):

<member name="P:XXX.XXX.XXX.XXX.Entitlement.Meta">
<summary> MUST REPLACE THE META SUMMARY. </summary>
</member>

Swagger gives this:
Image

The shared Swagger initialization logic:

/// <summary>
/// Implements extension methods invoked on startup.
/// </summary>
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);

                    userScopes.TryAdd(defaultScopeFqn, defaultScope);
                }

                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.UseAllOfToExtendReferenceSchemas();
                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.UseAllOfToExtendReferenceSchemas();
                        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<>))));
    }
}

I am out of ideas at this point because, as I mentioned, the same common logic produces another demo with no issues, so there must be something about my actual project and I'm not sure what it could be. I'll take a break and get back to it when I get a new idea to try (so far, nothing I did helped).

@alekdavis
Copy link
Author

Even more bizarre: I checked other complex properties and they have the correct documentation from the XML comments of the properties (not classes). For some reason, it's just one meta property I'm stuck on. I thought maybe it's the name, but no, in a demo project meta works fine. I do some special things with JSON.NET serialization, but I do not think serialization affects how documentation is generated. I'll keep looking.

@alekdavis
Copy link
Author

alekdavis commented Jan 31, 2025

@jgarciadelanoceda Okay, I think I figured it out. Here is what happens:

  • A common project builds a DLL with shared classes. Say, the output DLL and XML files are Common.dll, Common.xml. The common project has the class Meta defined with the class summary (Meta is just an example, name is not important).
  • An app project produces App.dll and App.xml. The app project has another class where Meta from Common.dll/xml is a property type of some other class. App has a different summary for the class property of type Meta.
  • When the app sets up Swagger XML sources, they get loaded in the alphabetic order: first, App.xml, then Common.xml.
  • The order of loading XML files is important. If Swagger setup logic loads App.xml first and Common.xml second, then descriptions of the Meta properties get overridden by the Meta class summary. If I rename App.xml to ZApp.xml before loading XMLs into Swagger, so ZApp.xml loads last, then all works as expected.

In summary, it appears that the latest loaded documentation (XML file) that has a summary of the class or property of the same class type wins.

Does this make sense? At least my explanation? Do you need a sample project to test this or is it clear enough?

@alekdavis
Copy link
Author

@jgarciadelanoceda I implemented a workaround by sorting the XML documentation files in the order of their create dates (the logic here is that the shared files will most likely have the older timestamp than the application files). In my long code sample above, I changed the XML file loading logic to this:

DirectoryInfo directoryInfo = new(AppContext.BaseDirectory);

// Get all XML files in the directory (from oldest to newest).
List<FileInfo> xmlFiles = directoryInfo.GetFiles("*.xml").OrderBy(file => file.CreationTime).ToList();

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

        XDocument xmlDoc = XDocument.Load(xmlFile.FullName);

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

If did the trick for me, but I cannot guarantee it will work in every case, so it would be better if the issue were addressed in Swashbuckle code and XML load order did not matter.

@jgarciadelanoceda
Copy link
Contributor

I'll look into it. It's possible that #3136 is related

@alekdavis
Copy link
Author

@jgarciadelanoceda It does sound related, but the point is that a class/type summary should not overwrite a property of that type summary regardless of the documentation source file order of loading. Property summary should always win. I guess if the property summary is missing, then the class/type summary would work, but that would be a fallback logic.

@jgarciadelanoceda
Copy link
Contributor

Sorry for the delay, I couldn't do it for now, because I have been taking care of a new child, I will look at it when I can

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug help-wanted A change up for grabs for contributions from the community
Projects
None yet
Development

No branches or pull requests

3 participants