Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix handling of FileResult's with content types #2841

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
martincostello marked this conversation as resolved.
Show resolved Hide resolved
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))]
martincostello marked this conversation as resolved.
Show resolved Hide resolved
#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; }
}
}
}