Swashbuckle.AspNetCore icon indicating copy to clipboard operation
Swashbuckle.AspNetCore copied to clipboard

[SwaggerSchema(ReadOnly = true)] not working in various cases

Open KillerBoogie opened this issue 1 year ago • 10 comments

Swashbuckle.AspNetCore 6.5.0 Swashbuckle.AspNetCore.Annotations 6.5.0. .Net 7

Goal

I was trying to model bind multiple sources to a single class and ignore some parameters with [SwaggerSchema(ReadOnly = true)]. I thought that this is a common scenario. E.g. environment parameters that are collected from HttpContext must not show as input parameters in Swagger UI. They don't come from request parameters and will be bound by a custom model binder.

Model binding to class doesn't work

I first had to realize that the basic binding doesn't work as it is documented at Microsoft Learn: Model Binding in ASP.NET Core. Currently, it seems that model binding to a single flat class does not work. I created an issue (https://github.com/dotnet/AspNetCore.Docs/issues/29295).

Partial Workaround

The only working options that I found is to have a sub class for the body as a parameter and either set the following option

builder.Services.AddMvc().ConfigureApiBehaviorOptions(options => {
    options.SuppressInferBindingSourcesForParameters = true;
});

or from .Net 6 on, mark the single class with `[FromQuery]' (which is not intuitive). This is not documented and it took me two days of frustration to find it in a comment of a post.

After this issue has a workaround I can continue to the main topic of this post: hide parameters from Swagger UI.

Attempt 1: [FromServices]

My first natural seeming attempt was to annotate the properties to be hidden with [FromServices].

[HttpPost]
public ActionResult Post([FromQuery] TestDetails testDetails)
{
    return Ok();
}

public record TestDetails
{
    [FromBody]
    public Artist? Artist{ get; init; }

    [FromServices]
    public string? IgnoreMe { get; init; }

    [FromServices, ModelBinder(typeof(EnvironmentBinder))]
    public IPAddress? IpAddress { get; init; }

    [FromHeader(Name = "Accept-Language")]
    public string? PreferredLanguages { get; init; }

    [FromQuery]
    public string? SelectedLanguage { get; init; }
}

public record Artist
{
    public string? Name { get; init; }

    [FromServices]
    public string? StageName { get; init; }
}

It worked partly. Properties from TestDetails are hidden in Swagger UI, but not the property in the Artist class. The properties are also not hidden from the models that are displayed below the endpoints in Swagger UI.

A partial workaround is to use two classes as parameters. One for the body parameters and one for the others (query, header, modelbinded, etc.).

[HttpPost]
public ActionResult PostTwoClassesfromQueryDetails(Artist artist, [FromQuery] RequestDetails requestDetails)
{...}

RequestDetails does not show up in the model list. But again [FromServices] does not work to hide a body property.

Attempt 2: [SwaggerSchema(ReadOnly = true)]

I then found the annotation package and the '[SwaggerSchema(ReadOnly = true)]` attribute. It tried it:

[HttpPost]
public ActionResult Post([FromQuery] TestDetails testDetails)
{
    return Ok();
}

public record TestDetails
{
    [FromBody]
    public Artist? Artist { get; init; }

    [SwaggerSchema(ReadOnly = true)]
    public string? IgnoreMe { get; init; }

    [SwaggerSchema(ReadOnly = true), ModelBinder(typeof(EnvironmentBinder))]
    public IPAddress? IpAddress { get; init; }

    // was removed from local tests, but is kept here to match the image
    [SwaggerSchema(ReadOnly = true), ModelBinder(typeof(DefaultBinder))]
    public Default? Default{ get; init; }

    [FromHeader(Name = "Accept-Language")]
    public string? PreferredLanguages { get; init; }

    [FromQuery]
    public string? SelectedLanguage { get; init; }
}

public record Artist
{
    public string? Name { get; init; }

    [SwaggerSchema(ReadOnly = true)]
    public string? StageName { get; init; }
}

Requests via Postman work, but in the resulting Swagger UI only the property in the Artistclass is hidden. [SwaggerSchema(ReadOnly = true)] does not work for the other parameters.

tests-controller-SuppressInferBindingSourcesForParameters-is-true

Is this a bug or just a bad design?

Attempt 3: [SwaggerIgnore]

My next approach was to use a custom [SwaggerIgnore] Filter. From the multiple versions and variations I found at (https://stackoverflow.com/questions/41005730/how-to-configure-swashbuckle-to-ignore-property-on-model) I chose this one:

public class SwaggerIgnoreFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (schema?.Properties == null)
        {
            return;
        }

        var excludedProperties = context.Type.GetProperties().Where(t => t.GetCustomAttribute<SwaggerIgnoreAttribute>() != null);

        foreach (var excludedProperty in excludedProperties)
        {
            var propertyToRemove = schema.Properties.Keys.SingleOrDefault(x => string.Equals(x, excludedProperty.Name, StringComparison.OrdinalIgnoreCase));

            if (propertyToRemove != null)
            {
                schema.Properties.Remove(propertyToRemove);
            }
        }
    }
}

While debugging I could see that the annotated property is removed from the schema, but it is still being displayed in Swagger UI. The code again works only for body properties.

Attempt 4: [OpenApiParameterIgnore]

I then found another promising solution using IOperationFilter:

public class OpenApiParameterIgnoreAttribute : System.Attribute
    {
    }

    public class OpenApiParameterIgnoreFilter : Swashbuckle.AspNetCore.SwaggerGen.IOperationFilter
    {
        public void Apply(Microsoft.OpenApi.Models.OpenApiOperation operation, Swashbuckle.AspNetCore.SwaggerGen.OperationFilterContext context)
        {
            if (operation == null || context == null || context.ApiDescription?.ParameterDescriptions == null)
                return;

            var parametersToHide = context.ApiDescription.ParameterDescriptions
                .Where(parameterDescription => ParameterHasIgnoreAttribute(parameterDescription))
                .ToList();

            if (parametersToHide.Count == 0)
                return;

            foreach (var parameterToHide in parametersToHide)
            {
                var parameter = operation.Parameters.FirstOrDefault(parameter => string.Equals(parameter.Name, parameterToHide.Name, System.StringComparison.Ordinal));
                if (parameter != null)
                    operation.Parameters.Remove(parameter);
            }
        }

        private static bool ParameterHasIgnoreAttribute(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiParameterDescription parameterDescription)
        {
            if (parameterDescription.ModelMetadata is Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.DefaultModelMetadata metadata)
            {
                bool result =  metadata.Attributes.ParameterAttributes?.Any(attribute => attribute.GetType() == typeof(OpenApiParameterIgnoreAttribute))??false;
                return result;
            }

            return false;
        }
    }
}

This time query, header, and model bound properties are hidden when the are directly in the method parameter list. E.g.:

[HttpPost]
public ActionResult PostBodyAndParams(
   Artist artist,
   [FromHeader(Name = "Accept-Language"), ModelBinder(typeof(LanguageBinder))] List<Language>? preferredLanguages,
   [FromQuery] string? selectedLanguage,
   [OpenApiParameterIgnore, ModelBinder(typeof(EnvironmentBinder))] IPAddress? ipAddress,
   [OpenApiParameterIgnore] string? ignoreMe
)

But it didn't work if the parameters where inside a class, like the examples above.

Partial Workaround

After a lot of debugging and looking at the objects I figured out that in the method the parameters are considered ParameterAttributes, but inside the class they are PropertyAttributes and therefore not selected in the above code.

The solution is to change metadata.Attributes.ParameterAttributes to metadata.Attributes.Attributes. Now both parameter and property attributes are selected and removed.

It still doesn't work for the parameter inside the body class, because the Artist class is treated as one property. Flattening doesn't work due to the bug in the API Explorer. I don't understand how to extend the code so that it would work also inside the body. Who can help?

Also the IOperationFilter doesn't remove the parameter from the displayed model in SwaggerUI. It requires an additional ISchemaFilter or the usage of [SwaggerSchema(ReadOnly = true)] .

Conclusion and Feature/UpdateRequest

I'm very frustrated with Swagger and Asp.Net Core. To achieve a basic common pattern that is documented at Microsoft Learn one must jump through hoops and waste valuable time for tricking the framework instead of working on the business domain. Then there is no way out of the box to hide parameters and the extention library doesn't work.

Please update [SwaggerSchema(ReadOnly = true)] so that:

  • it works with method parameters or class properties
  • removes input fields from SwaggerUI and displayed model

KillerBoogie avatar May 12 '23 19:05 KillerBoogie