diff --git a/README.md b/README.md index fa78a74f0..111df744d 100644 --- a/README.md +++ b/README.md @@ -508,13 +508,12 @@ public void UploadFile([FromForm]string description, [FromForm]DateTime clientDa > Important note: As per the [ASP.NET Core docs](https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-3.1), you're not supposed to decorate `IFormFile` parameters with the `[FromForm]` attribute as the binding source is automatically inferred from the type. In fact, the inferred value is `BindingSource.FormFile` and if you apply the attribute it will be set to `BindingSource.Form` instead, which screws up `ApiExplorer`, the metadata component that ships with ASP.NET Core and is heavily relied on by Swashbuckle. One particular issue here is that SwaggerUI will not treat the parameter as a file and so will not display a file upload button, if you do mistakenly include this attribute. ### Handle File Downloads ### -`ApiExplorer` (the ASP.NET Core metadata component that Swashbuckle is built on) *DOES NOT* surface the `FileResult` type by default and so you need to explicitly tell it to with the `Produces` attribute: +`ApiExplorer` (the ASP.NET Core metadata component that Swashbuckle is built on) *DOES NOT* surface the `FileResult` types by default and so you need to explicitly tell it to with the `ProducesResponseType` attribute (or `Produces` on .NET 5 or older): ```csharp [HttpGet("{fileName}")] -[Produces("application/octet-stream", Type = typeof(FileResult))] -public FileResult GetFile(string fileName) +[ProducesResponseType(typeof(FileStreamResult), StatusCodes.Status200OK, "image/jpeg")] +public FileStreamResult GetFile(string fileName) ``` -If you want the swagger-ui to display a "Download file" link, you're operation will need to return a **Content-Type of "application/octet-stream"** or a **Content-Disposition of "attachement"**. ### Include Descriptions from XML Comments ### diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs index 88b46d4a2..dbb642faf 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs @@ -602,15 +602,18 @@ private IEnumerable InferRequestContentTypes(ApiDescription apiDescripti Description = description, Content = responseContentTypes.ToDictionary( contentType => contentType, - contentType => CreateResponseMediaType(apiResponseType.ModelMetadata, schemaRepository) + contentType => CreateResponseMediaType(apiResponseType.ModelMetadata?.ModelType ?? apiResponseType.Type, schemaRepository) ) }; } private IEnumerable InferResponseContentTypes(ApiDescription apiDescription, ApiResponseType apiResponseType) { - // If there's no associated model, return an empty list (i.e. no content) - if (apiResponseType.ModelMetadata == null) return Enumerable.Empty(); + // If there's no associated model type, return an empty list (i.e. no content) + if (apiResponseType.ModelMetadata == null && (apiResponseType.Type == null || apiResponseType.Type == typeof(void))) + { + return Enumerable.Empty(); + } // If there's content types explicitly specified via ProducesAttribute, use them var explicitContentTypes = apiDescription.CustomAttributes().OfType() @@ -627,11 +630,11 @@ private IEnumerable InferResponseContentTypes(ApiDescription apiDescript return Enumerable.Empty(); } - private OpenApiMediaType CreateResponseMediaType(ModelMetadata modelMetadata, SchemaRepository schemaRespository) + private OpenApiMediaType CreateResponseMediaType(Type modelType, SchemaRepository schemaRespository) { return new OpenApiMediaType { - Schema = GenerateSchema(modelMetadata.ModelType, schemaRespository) + Schema = GenerateSchema(modelType, schemaRespository) }; } diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeController.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeController.cs index a25c6f7e5..477137d0d 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeController.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/Fixtures/FakeController.cs @@ -91,6 +91,12 @@ public int ActionWithProducesAttribute() throw new NotImplementedException(); } + [ProducesResponseType(typeof(FileContentResult), 200, "application/zip")] + public FileContentResult ActionWithFileResult() + { + throw new NotImplementedException(); + } + [SwaggerIgnore] public void ActionWithSwaggerIgnoreAttribute() { } diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs index d448f825d..637f2589d 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SwaggerGenerator/SwaggerGeneratorTests.cs @@ -5,12 +5,12 @@ using System.Threading.Tasks; using System.Reflection; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; @@ -968,6 +968,40 @@ public void GetSwagger_GeneratesResponses_ForSupportedResponseTypes() Assert.Empty(responseDefault.Content.Keys); } + [Fact] + public void GetSwagger_SetsResponseContentType_WhenActionHasFileResult() + { + var apiDescription = ApiDescriptionFactory.Create( + c => nameof(c.ActionWithFileResult), + groupName: "v1", + httpMethod: "POST", + relativePath: "resource", + supportedResponseTypes: new[] + { + new ApiResponseType + { + ApiResponseFormats = new [] { new ApiResponseFormat { MediaType = "application/zip" } }, + StatusCode = 200, + Type = typeof(FileContentResult) + } + }); + + // ASP.NET Core sets ModelMetadata to null for FileResults + apiDescription.SupportedResponseTypes[0].ModelMetadata = null; + + var subject = Subject( + apiDescriptions: new[] { apiDescription } + ); + + var document = subject.GetSwagger("v1"); + + var operation = document.Paths["/resource"].Operations[OperationType.Post]; + var content = operation.Responses["200"].Content.FirstOrDefault(); + Assert.Equal("application/zip", content.Key); + Assert.Equal("binary", content.Value.Schema.Format); + Assert.Equal("string", content.Value.Schema.Type); + } + [Fact] public void GetSwagger_SetsResponseContentTypesFromAttribute_IfActionHasProducesAttribute() { diff --git a/test/WebSites/Basic/Controllers/FilesController.cs b/test/WebSites/Basic/Controllers/FilesController.cs index 8021f4365..0d09ffbdf 100644 --- a/test/WebSites/Basic/Controllers/FilesController.cs +++ b/test/WebSites/Basic/Controllers/FilesController.cs @@ -27,7 +27,11 @@ public IActionResult PostFormWithFile([FromForm]FormWithFile formWithFile) } [HttpGet("{name}")] - [Produces("application/octet-stream", Type = typeof(FileResult))] +#if NET6_0_OR_GREATER + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK, "text/plain", "application/zip")] +#else + [Produces("text/plain", "application/zip", Type = typeof(FileResult))] +#endif public FileResult GetFile(string name) { var stream = new MemoryStream(); @@ -37,7 +41,9 @@ public FileResult GetFile(string name) writer.Flush(); stream.Position = 0; - return File(stream, "application/octet-stream", name); + var contentType = name.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase) ? "application/zip" : "text/plain"; + + return File(stream, contentType, name); } } @@ -47,4 +53,4 @@ public class FormWithFile public IFormFile File { get; set; } } -} \ No newline at end of file +}