Skip to content

Commit

Permalink
Fix handling of FileResult's with content types (#2841)
Browse files Browse the repository at this point in the history
When ProducesResponseType is used with FileResult subclasses and explicit content types, ApiExplorer sets ModelMetadata to null. We were then erroneously treating it as returning no content.

Resolves #2320, #2386
  • Loading branch information
IGx89 committed Apr 29, 2024
1 parent 05409d5 commit 713c8e5
Show file tree
Hide file tree
Showing 5 changed files with 61 additions and 13 deletions.
7 changes: 3 additions & 4 deletions README.md
Expand Up @@ -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 ###

Expand Down
Expand Up @@ -602,15 +602,18 @@ private IEnumerable<string> 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<string> 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<string>();
// 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<string>();
}

// If there's content types explicitly specified via ProducesAttribute, use them
var explicitContentTypes = apiDescription.CustomAttributes().OfType<ProducesAttribute>()
Expand All @@ -627,11 +630,11 @@ private IEnumerable<string> InferResponseContentTypes(ApiDescription apiDescript
return Enumerable.Empty<string>();
}

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)
};
}

Expand Down
Expand Up @@ -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()
{ }
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -968,6 +968,40 @@ public void GetSwagger_GeneratesResponses_ForSupportedResponseTypes()
Assert.Empty(responseDefault.Content.Keys);
}

[Fact]
public void GetSwagger_SetsResponseContentType_WhenActionHasFileResult()
{
var apiDescription = ApiDescriptionFactory.Create<FakeController>(
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()
{
Expand Down
12 changes: 9 additions & 3 deletions test/WebSites/Basic/Controllers/FilesController.cs
Expand Up @@ -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();
Expand All @@ -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);
}
}

Expand All @@ -47,4 +53,4 @@ public class FormWithFile

public IFormFile File { get; set; }
}
}
}

0 comments on commit 713c8e5

Please sign in to comment.