hybrid-model-binding icon indicating copy to clipboard operation
hybrid-model-binding copied to clipboard

Swashbuckle/Swagger integration

Open JamesCrann opened this issue 5 years ago • 8 comments

Hi,

I am having great success in using hybrid model binding to bind from route and body to a single model. However the downside is that swashbuckle no longer functions correctly when generating swagger documentation.

public class AddOrUpdatePhoneNumbersRequest
    {
        [HybridBindProperty(Source.Route)]
        public string PersonId { get; set; }

        [HybridBindProperty(Source.Body)]
        public string Mobile { get; set; }

        [HybridBindProperty(Source.Body)]
        public string Home { get; set; }

        [HybridBindProperty(Source.Body)]
        public string Work { get; set; }

        [HybridBindProperty(Source.Body)]
        public PhoneNumberType PreferredPhoneNumberType { get; set; }
    }

Instead of the document example being displayed we just see the empty {} - also noting that the body thinks its coming from the query

image

Has anybody previously looked at adding a swashbuckle filter to correctly display the properties bound on the body?

JamesCrann avatar Feb 15 '19 03:02 JamesCrann

I'll have to spin-up a sample project to see what's going on.

Will try and get to it this week.

billbogaiv avatar Mar 05 '19 02:03 billbogaiv

@JamesCrann: here's a sample that works with Swashbuckle.AspNetCore v4.0.1. v5.x works completely different. I may eventually incorporate this logic in some kind of HybridModelBinding.Swashbuckle package. If I do, I'll make sure to reference this issue.

Somewhere in Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddSwaggerGen(x =>
    {
        // x.SwaggerDoc("v1", new Info { Title = "My API", Version = "v1" });

        x.OperationFilter<HybridOperationFilter>();
    });
}

Quickly hacked-together filter to make Hybrid-sources appear the same as [FromBody] sources

public class HybridOperationFilter : IOperationFilter
{
    public void Apply(Operation operation, OperationFilterContext context)
    {
        var hybridParameters = context.ApiDescription.ParameterDescriptions
            .Where(x => x.Source.Id == "Hybrid")
            .Select(x => new
            {
                name = x.Name,
                schema = context.SchemaRegistry.GetOrRegister(x.Type)
            }).ToList();

        for (var i = 0; i < operation.Parameters.Count; i++)
        {
            for (var j = 0; j < hybridParameters.Count; j++)
            {
                if (hybridParameters[j].name == operation.Parameters[i].Name)
                {
                    var name = operation.Parameters[i].Name;
                    var isRequired = operation.Parameters[i].Required;

                    operation.Parameters.RemoveAt(i);

                    operation.Parameters.Insert(i, new BodyParameter()
                    {
                        Name = name,
                        Required = isRequired,
                        Schema = hybridParameters[j].schema,
                    });
                }
            }
        }
    }
}

Some relevant references:

https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/v4.0.1/src/Swashbuckle.AspNetCore.SwaggerGen/Generator/SwaggerGenerator.cs#L259-L271

https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/v5.0.0-rc2/src/Swashbuckle.AspNetCore.SwaggerGen/Generator/SwaggerGenerator.cs#L221-L227

billbogaiv avatar Mar 26 '19 01:03 billbogaiv

v5.x version... At least its working for me...

public class HybridOperationFilter : IOperationFilter
{
    public void Apply(Operation operation, OperationFilterContext context)
    {
        var hybridParameters = context.ApiDescription.ParameterDescriptions
            .Where(x => x.Source.Id == "Hybrid")
            .Select(x => new { name = x.Name }).ToList();

        for (var i = 0; i < operation.Parameters.Count; i++)
        {
            for (var j = 0; j < hybridParameters.Count; j++)
            {
                if (hybridParameters[j].name == operation.Parameters[i].Name)
                {
                    var name = operation.Parameters[i].Name;
                    var isRequired = operation.Parameters[i].Required;
                    var hybridMediaType = new OpenApiMediaType { Schema = operation.Parameters[i].Schema };

                    operation.Parameters.RemoveAt(i);

                    operation.RequestBody = new OpenApiRequestBody
                    {
                        Content = new Dictionary<string, OpenApiMediaType>
                        {
                            //You are not limited to "application/json"...
                            //If you add more just ensure they use the same hybridMediaType
                            { "application/json", hybridMediaType }
                        },
                        Required = isRequired
                    };
                }
            }
        }
    }
}

NOTE: Since you probably don't want the properties that are going to be mapped from other sources to show up in your Swashbuckle UI schema definition you might want to implement the following two classes also...

    [AttributeUsage(AttributeTargets.Property)]
    public class SwaggerIgnoreAttribute: Attribute { }

And...

public class SwaggerIgnoreFilter : ISchemaFilter
    {
        public void Apply(OpenApiSchema schema, SchemaFilterContext context)
        {
            if (!(context.ApiModel is ApiObject))
            {
                return;
            }

            var model = context.ApiModel as ApiObject;

            if (schema?.Properties == null || model?.ApiProperties == null)
            {
                return;
            }

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

            var excludedSchemaProperties = model.ApiProperties
                   .Where(
                        ap => excludedProperties.Any(
                            pi => pi.Name == ap.MemberInfo.Name
                        )
                    );

            foreach (var propertyToExclude in excludedSchemaProperties)
            {
                schema.Properties.Remove(propertyToExclude.ApiName);
            }
        }
    }

Don't forget to add the following to your Startup.cs => services.AddSwaggerGen() method.

    c.SchemaFilter<SwaggerIgnoreFilter>();
    c.OperationFilter<HybridOperationFilter>();

Add the Parameter summary comments, to your Action method, for the Hybrid model properties that are NOT coming from the RequestBody, and add them to the Action method signature...

        /// <summary>
        /// Create/Update  Record
        /// </summary>
        /// <param name="filetype">FileType</param>
        /// <param name="filenumber">FileNumber</param>
        /// <param name="recordVM"><see cref="RecordViewModel"/></param>
        /// <returns><see cref="OkObjectResult"/> or an Error Result</returns>
        [HttpPost("~/api/record/{filetype}/{filenumber}")]
        public IActionResult Upsert(
            [FromHybrid, Required]RecordViewModel recordVM, 
            [Required]string filetype, 
            [Required]string filenumber)
        {
            return Ok(_recordService.Upsert(recordVM));
        }

And add the SwaggerIgnore Attribute (Defined above) to the Hybrid model properties that are NOT coming from the RequestBody

    public class RecordViewModel
    {
        [Required]
        public string Text { get; set; } //Will be added by the HybridModelBinder from the Request Body
        [Required]
        public DateTime TransactionDate { get; set; }  //Will be added by the HybridModelBinder from the Request Body
        [Required]
        [MaxLength(10)]
        public string UserId { get; set; }  //Will be added by the HybridModelBinder from the Request Body
        [Required]
        [SwaggerIgnore]
        public string FileType { get; set; } //Will be added by the HybridModelBinder from the Path
        [Required]
        [SwaggerIgnore]
        public string FileNumber { get; set; } //Will be added by the HybridModelBinder from the Path
    }

dkimbell13 avatar Nov 29 '19 21:11 dkimbell13

Thanks, @dkimbell13, for the update/code sample. I haven't forgotten about this issue. Been going through and doing some house-cleaning on the project today. I've got a list of TODOs getting the project ready for a 1.0-release 🎉 and will circle-back once that's complete.

billbogaiv avatar Jan 11 '20 15:01 billbogaiv

Thanks for these filters, they work well.

If anyone knows a way to source the route parameter comments from the model property, that would be great. I see the comment above about leaving the parameters on the method and commenting them there, but the parameters are completely unused (due to being bound to the model) and I'd love to be able to remove them from the method.

stevendarby avatar Feb 06 '20 13:02 stevendarby

I have a similar situation, but with the query string rather than the body. I have a query model with the Id coming from the route path and the fields and include properties coming from the query string.

    public class GetQuery
    {
        [HybridBindProperty(Source.Route)]
        public long Id { get; set; }
        [HybridBindProperty(Source.QueryString)]
        public string Fields { get; set; }
        [HybridBindProperty(Source.QueryString)]
        public string Include { get; set; }
    }

The API controller:

    [HttpGet("{id}", Name = nameof(GetAsync))]
    public async Task<ActionResult> GetAsync([FromHybrid] GetQuery query, CancellationToken ct = default)
    {
     ...
    }

Currently, SwaggerUI is getting generated like this: HybridSwagger_Actual

But my goal would be for it to look like this: HybridSwagger_Goal

Any assistance would be appreciated.

bryanllewis avatar Apr 27 '20 23:04 bryanllewis

Same issues here... I wish there was a workaround.

bricejar avatar Nov 26 '20 04:11 bricejar

Any updates on this?

Misiu avatar Mar 01 '22 10:03 Misiu