domaindrivendev / Swashbuckle.AspNetCore

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

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

Closed KillerBoogie closed 3 days ago

KillerBoogie commented 1 year ago

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:

nqbjnh commented 1 year ago

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

I reviewed AnnotationsSchemaFilter in Swashbuckle.AspNetCore.Annotations

I can not find any places remove property from schema (schema.Properties.Remove(excludedName))

I created another filter and use SwaggerSchemaAttribute in Swashbuckle.AspNetCore.Annotations

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

        const BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
        var memberList = context.Type
            .GetFields(bindingFlags).Cast<MemberInfo>()
            .Concat(context.Type.GetProperties(bindingFlags));

        var excludedList = memberList
            .Where(m => m.GetCustomAttribute<SwaggerSchemaAttribute>() != null)
            .Select(m => m.GetCustomAttribute<JsonPropertyAttribute>()?.PropertyName ?? m.Name.ToCamelCase());

        foreach (var excludedName in excludedList)
        {
            if (schema.Properties.ContainsKey(excludedName))
                schema.Properties.Remove(excludedName);
        }
    }
}

public static class StringExtension
{
    public static string ToCamelCase(this string str)
    {
        if (!string.IsNullOrEmpty(str) && str.Length > 1)
        {
            return char.ToLowerInvariant(str[0]) + str.Substring(1);
        }
        return str.ToLowerInvariant();
    }
}

and program.cs add following

builder.Services.AddSwaggerGen(options =>
{
    options.SchemaFilter<SwaggerIgnoreFilter>();
}

It is working perfect

syedsuhaib commented 11 months ago

Is SwaggerSchemaAttribute respected while genration open API spec in minimal APIs?

marcelofilhomagicmedia commented 9 months ago

any updates?

Havunen commented 8 months ago

Attempt 1: [FromServices]

[FromServices] parameters are not shown in DotSwashbuckle, can you test if that solves your problem?

bCamba commented 8 months ago

I am also having this problem

etkinpinar commented 8 months ago

You can try putting [BindNever] attribute over the property you don't want to be shown in swagger ui, as suggested in this comment. However this attribute might only work with [FromQuery], I've seen some discussion why its not working with [FromBody].

github-actions[bot] commented 4 months ago

This issue is stale because it has been open for 60 days with no activity. It will be automatically closed in 14 days if no further updates are made.

jgarciadelanoceda commented 4 months ago

@KillerBoogie, with the last version of Swashbuckle, there's a attribute that is SwagerIgnore, that would do the trick. However as I have seen you are trying to do not show some parameters of the body, in case the parameters are meant to be hidden but accesible you can put the SwaggerIgnore in the properties of the body.

But if you do not want to deserialize those properties at all the JsonIgnore is your way to go.

Also mention the BindNever attribute for properties that are not in the body

KillerBoogie commented 4 months ago

Thanks for the information! I have been off the project for a while. I can check it next week.

adailey-sw commented 2 months ago

I found that to get [SwaggerSchema(ReadOnly = true)] to work, you have to enable swagger annotations elsewhere in your project.

For my project, that was within Program.cs like this:

builder.Services.AddSwaggerGen(c =>
{
    c.EnableAnnotations(); // Enable annotations in Swagger
});

After that, the ReadOnly annotation worked as I expected.

github-actions[bot] commented 2 weeks ago

This issue is stale because it has been open for 60 days with no activity. It will be automatically closed in 14 days if no further updates are made.

github-actions[bot] commented 3 days ago

This issue was closed because it has been inactive for 14 days since being marked as stale.