Skip to content

Instantly share code, notes, and snippets.

@mburumaxwell
Last active September 14, 2021 05:12
Show Gist options
  • Save mburumaxwell/325c26e098fa153676d9ddf192307d9c to your computer and use it in GitHub Desktop.
Save mburumaxwell/325c26e098fa153676d9ddf192307d9c to your computer and use it in GitHub Desktop.
Serving Azure Blobs using custom ActionResult and IActionResultExecutor
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);
}
}
}
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);
}
}
}
}
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;
}
}
}
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");
}
}
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