Last active
September 14, 2021 05:12
-
-
Save mburumaxwell/325c26e098fa153676d9ddf192307d9c to your computer and use it in GitHub Desktop.
Serving Azure Blobs using custom ActionResult and IActionResultExecutor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Microsoft.AspNetCore.Mvc.Infrastructure; | |
using Microsoft.Extensions.DependencyInjection; | |
using System; | |
using System.Threading.Tasks; | |
namespace Microsoft.AspNetCore.Mvc | |
{ | |
/// <summary> | |
/// Represents an <see cref="ActionResult"/> that when executed | |
/// will write a file from a blob to the response. | |
/// </summary> | |
public class BlobStorageResult : FileResult | |
{ | |
public BlobStorageResult(string blobUrl, string contentType) : base(contentType) | |
{ | |
if (string.IsNullOrWhiteSpace(BlobUrl = blobUrl)) | |
{ | |
throw new ArgumentException($"'{nameof(blobUrl)}' cannot be null or whitespace.", nameof(blobUrl)); | |
} | |
} | |
/// <summary>Gets the URL for the block blob to be returned.</summary> | |
public string BlobUrl { get; } | |
/// <summary>Gets or sets the <c>Content-Type</c> header if the value is already known.</summary> | |
public long? ContentLength { get; set; } | |
/// <summary> | |
/// Gets or sets whether blob properties should be retrieved before downloading. | |
/// Useful when the file size is not known in advance | |
/// </summary> | |
public bool GetPropertiesBeforeDownload { get; set; } | |
/// <inheritdoc/> | |
public override Task ExecuteResultAsync(ActionContext context) | |
{ | |
if (context == null) throw new ArgumentNullException(nameof(context)); | |
var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<BlobStorageResult>>(); | |
return executor.ExecuteAsync(context, this); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Azure; | |
using Azure.Storage.Blobs; | |
using Microsoft.AspNetCore.Http; | |
using Microsoft.Extensions.Logging; | |
using Microsoft.Net.Http.Headers; | |
using System; | |
using System.IO; | |
using System.Threading.Tasks; | |
namespace Microsoft.AspNetCore.Mvc.Infrastructure | |
{ | |
public class BlobStorageResultExecutor : FileResultExecutorBase, IActionResultExecutor<BlobStorageResult> | |
{ | |
private readonly BlobServiceClient blobServiceClient; | |
public BlobStorageResultExecutor(BlobServiceClient blobServiceClient, ILoggerFactory loggerFactory) | |
: base(CreateLogger<BlobStorageResultExecutor>(loggerFactory)) | |
{ | |
this.blobServiceClient = blobServiceClient ?? throw new ArgumentNullException(nameof(blobServiceClient)); | |
} | |
/// <inheritdoc/> | |
public async Task ExecuteAsync(ActionContext context, BlobStorageResult result) | |
{ | |
if (context == null) throw new ArgumentNullException(nameof(context)); | |
if (result == null) throw new ArgumentNullException(nameof(result)); | |
var cancellationToken = context.HttpContext.RequestAborted; | |
var bub = new BlobUriBuilder(new Uri(result.BlobUrl)); | |
var containerClient = blobServiceClient.GetBlobContainerClient(bub.BlobContainerName); | |
var client = containerClient.GetBlobClient(bub.BlobName); | |
Logger.ExecutingBlobStorageResult(result); | |
if (HttpMethods.IsHead(context.HttpContext.Request.Method)) | |
{ | |
// if values are not set, pull them from blob properties | |
if (result.ContentLength is null || result.LastModified is null || result.EntityTag is null) | |
{ | |
// Get the properties of the blob | |
var response = await client.GetPropertiesAsync(cancellationToken: cancellationToken); | |
var properties = response.Value; | |
result.ContentLength ??= properties.ContentLength; | |
result.LastModified ??= properties.LastModified; | |
result.EntityTag ??= MakeEtag(properties.ETag); | |
} | |
SetHeadersAndLog(context: context, | |
result: result, | |
fileLength: result.ContentLength, | |
enableRangeProcessing: result.EnableRangeProcessing, | |
lastModified: result.LastModified, | |
etag: result.EntityTag); | |
} | |
else | |
{ | |
// if values are not set, pull them from blob properties | |
if (result.GetPropertiesBeforeDownload) | |
{ | |
// Get the properties of the blob | |
var arp = await client.GetPropertiesAsync(cancellationToken: cancellationToken); | |
var properties = arp.Value; | |
result.ContentLength ??= properties.ContentLength; | |
result.LastModified ??= properties.LastModified; | |
result.EntityTag ??= MakeEtag(properties.ETag); | |
} | |
var (range, rangeLength, serveBody) = SetHeadersAndLog(context: context, | |
result: result, | |
fileLength: result.ContentLength, | |
enableRangeProcessing: result.EnableRangeProcessing, | |
lastModified: result.LastModified, | |
etag: result.EntityTag); | |
if (!serveBody) | |
{ | |
return; | |
} | |
// Download the blob in the specified range | |
var hr = range is not null ? new HttpRange(range.From!.Value, rangeLength) : default; | |
var response = await client.DownloadStreamingAsync(hr, cancellationToken: cancellationToken); | |
// if LastModified and ETag are not set, pull them from streaming result | |
var bdr = response.Value; | |
var details = bdr.Details; | |
if (result.LastModified is null || result.EntityTag is null) | |
{ | |
var httpResponseHeaders = context.HttpContext.Response.GetTypedHeaders(); | |
httpResponseHeaders.LastModified = result.LastModified ??= details.LastModified; | |
httpResponseHeaders.ETag = result.EntityTag ??= MakeEtag(details.ETag); | |
} | |
var stream = bdr.Content; | |
using (stream) | |
{ | |
await WriteAsync(context, stream); | |
} | |
} | |
} | |
/// <summary> | |
/// Write the contents of the <see cref="BlobStorageResult"/> to the response body. | |
/// </summary> | |
/// <param name="context">The <see cref="ActionContext"/>.</param> | |
/// <param name="stream">The <see cref="Stream"/> to write.</param> | |
protected virtual Task WriteAsync(ActionContext context, Stream stream) | |
{ | |
if (context == null) throw new ArgumentNullException(nameof(context)); | |
if (stream == null) throw new ArgumentNullException(nameof(stream)); | |
return WriteFileAsync(context: context.HttpContext, | |
fileStream: stream, | |
range: null, // prevent seeking | |
rangeLength: 0); | |
} | |
private static EntityTagHeaderValue MakeEtag(ETag eTag) => new(eTag.ToString("H")); | |
} | |
internal static partial class Log | |
{ | |
private static readonly Action<ILogger, string, string, Exception?> _executingFileResultWithNoFileName | |
= LoggerMessage.Define<string, string>( | |
eventId: new EventId(1, nameof(ExecutingBlobStorageResult)), | |
logLevel: LogLevel.Information, | |
formatString: "Executing {FileResultType}, sending file with download name '{FileDownloadName}'"); | |
private static readonly Action<ILogger, Exception?> _writingRangeToBody | |
= LoggerMessage.Define( | |
eventId: new EventId(17, nameof(WritingRangeToBody)), | |
logLevel: LogLevel.Debug, | |
formatString: "Writing the requested range of bytes to the body."); | |
public static void ExecutingBlobStorageResult(this ILogger logger, BlobStorageResult result) | |
{ | |
if (logger.IsEnabled(LogLevel.Information)) | |
{ | |
var resultType = result.GetType().Name; | |
_executingFileResultWithNoFileName(logger, resultType, result.FileDownloadName, null); | |
} | |
} | |
public static void WritingRangeToBody(this ILogger logger) | |
{ | |
if (logger.IsEnabled(LogLevel.Debug)) | |
{ | |
_writingRangeToBody(logger, null); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Azure.Storage.Blobs; | |
using Microsoft.AspNetCore.Mvc; | |
using Microsoft.AspNetCore.Mvc.Infrastructure; | |
using Microsoft.Extensions.Configuration; | |
using System; | |
namespace Microsoft.Extensions.DependencyInjection | |
{ | |
public static class IServiceCollectionExtensions | |
{ | |
public static IServiceCollection AddBlobStorage(this IServiceCollection services, IConfiguration configuration) | |
{ | |
if (services == null) throw new ArgumentNullException(nameof(services)); | |
// Endpoint should look like "https://{account_name}.blob.core.windows.net" except for emulator | |
var endpoint = configuration.GetValue<string>("Endpoint") ?? "UseDevelopmentStorage=true;"; | |
var containerName = configuration.GetValue<string>("ContainerName"); | |
services.AddSingleton<BlobServiceClient>(provider => | |
{ | |
// Create the credential, you can use connection string instead | |
var credential = new DefaultAzureCredential(); | |
/* | |
* TokenCredential cannot be used with non https scheme. | |
* So we have to check if we are local and use a connection string instead. | |
* https://github.com/Azure/azure-sdk/issues/2195 | |
*/ | |
var isLocal = endpoint.StartsWith("http://127.0.0.1") || endpoint.Contains("UseDevelopmentStorage"); | |
return isLocal | |
? new BlobServiceClient(connectionString: "UseDevelopmentStorage=true;") | |
: new BlobServiceClient(serviceUri: new Uri(endpoint), credential: credential); | |
}); | |
services.AddScoped<BlobContainerClient>(provider => | |
{ | |
var serviceClient = provider.GetRequiredService<BlobServiceClient>(); | |
return serviceClient.GetBlobContainerClient(containerName); | |
}); | |
services.AddSingleton<IActionResultExecutor<BlobStorageResult>, BlobStorageResultExecutor>(); | |
return services; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class SampleController : ControllerBase | |
{ | |
private readonly BlobServiceClient blobServiceClient; | |
public SampleController(BlobServiceClient blobServiceClient) | |
{ | |
this.blobServiceClient = blobServiceClient ?? throw new ArgumentNullException(nameof(blobServiceClient)); | |
} | |
[HttpGet] | |
public async Task<IActionResult> DownloadAsync() | |
{ | |
// getting blob storage url from database or other sources, omitted for simplicity | |
var containerClient = blobServiceClient.GetContainerClient("container1"); | |
var blobClient = containerClient.GetBlobClient("dir1/file1.pdf"); | |
using var ms = new MemoryStream(); | |
await blobClient.DownloadToAsync(ms); | |
ms.Seek(0, SeekOrigin.Begin); | |
return File(ms, "application/pdf"); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class SampleController : ControllerBase | |
{ | |
[HttpGet] | |
[HttpHead] | |
public IActionResult DownloadAsync() | |
{ | |
// getting blob storage url from database or other sources, omitted for simplicity | |
var blobUrl = "https://contoso.blob.core.windows.net/container1/dir1/file1.pdf"; | |
return new BlobStorageResult(blobUrl, "application/pdf"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment