Runtime integration for static web assets (#10632)

* Runtime integration for static web assets
* Adds an extension method to IWebHostBuilder to plug-in static web
  assets.
* Plugs-in static web assets in development by default.
This commit is contained in:
Javier Calvarro Nelson 2019-05-31 09:04:59 +02:00 committed by GitHub
parent 6abb874205
commit 62c190da6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 645 additions and 1 deletions

View File

@ -17,6 +17,7 @@
<Reference Include="Microsoft.Extensions.Configuration.Json" />
<Reference Include="Microsoft.Extensions.Configuration.CommandLine" />
<Reference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />
<Reference Include="Microsoft.Extensions.Logging" />
<Reference Include="Microsoft.Extensions.Logging.Configuration" />
<Reference Include="Microsoft.Extensions.Logging.Console" />

View File

@ -3,6 +3,10 @@
namespace Microsoft.AspNetCore
{
public static partial class StaticWebAssetsWebHostBuilderExtensions
{
public static Microsoft.AspNetCore.Hosting.IWebHostBuilder UseStaticWebAssets(this Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { throw null; }
}
public static partial class WebHost
{
public static Microsoft.AspNetCore.Hosting.IWebHostBuilder CreateDefaultBuilder() { throw null; }

View File

@ -21,6 +21,7 @@
<Reference Include="Microsoft.Extensions.Configuration.Json" PrivateAssets="None" />
<Reference Include="Microsoft.Extensions.Configuration.CommandLine" PrivateAssets="None" />
<Reference Include="Microsoft.Extensions.Configuration.UserSecrets" PrivateAssets="None" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" PrivateAssets="None" />
<Reference Include="Microsoft.Extensions.Logging" PrivateAssets="None" />
<Reference Include="Microsoft.Extensions.Logging.Configuration" PrivateAssets="None" />
<Reference Include="Microsoft.Extensions.Logging.Console" PrivateAssets="None" />

View File

@ -0,0 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -0,0 +1,77 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Runtime.InteropServices;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore
{
// A file provider used for serving static web assets from referenced projects and packages during development.
// The file provider maps folders from referenced projects and packages and prepends a prefix to their relative
// paths.
// At publish time the assets end up in the wwwroot folder of the published app under the prefix indicated here
// as the base path.
// For example, for a referenced project mylibrary with content under <<mylibrarypath>>\wwwroot will expose
// static web assets under _content/mylibrary (this is by convention). The path prefix or base path we apply
// is that (_content/mylibrary).
// when the app gets published, the build pipeline puts the static web assets for mylibrary under
// publish/wwwroot/_content/mylibrary/sample-asset.js
// To allow for the same experience during development, StaticWebAssetsFileProvider maps the contents of
// <<mylibrarypath>>\wwwroot\** to _content/mylibrary/**
internal class StaticWebAssetsFileProvider : IFileProvider
{
private static readonly StringComparison FilePathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
StringComparison.OrdinalIgnoreCase :
StringComparison.Ordinal;
public StaticWebAssetsFileProvider(string pathPrefix, string contentRoot)
{
BasePath = new PathString(pathPrefix.StartsWith("/") ? pathPrefix : "/" + pathPrefix);
InnerProvider = new PhysicalFileProvider(contentRoot);
}
public PhysicalFileProvider InnerProvider { get; }
public PathString BasePath { get; }
/// <inheritdoc />
public IDirectoryContents GetDirectoryContents(string subpath)
{
if (!StartsWithBasePath(subpath, out var physicalPath))
{
return NotFoundDirectoryContents.Singleton;
}
else
{
return InnerProvider.GetDirectoryContents(physicalPath);
}
}
/// <inheritdoc />
public IFileInfo GetFileInfo(string subpath)
{
if (!StartsWithBasePath(subpath, out var physicalPath))
{
return new NotFoundFileInfo(subpath);
}
else
{
return InnerProvider.GetFileInfo(physicalPath);
}
}
/// <inheritdoc />
public IChangeToken Watch(string filter)
{
return InnerProvider.Watch(filter);
}
private bool StartsWithBasePath(string subpath, out PathString rest)
{
return new PathString(subpath).StartsWithSegments(BasePath, FilePathComparison, out rest);
}
}
}

View File

@ -0,0 +1,96 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
namespace Microsoft.AspNetCore
{
internal class StaticWebAssetsLoader
{
internal const string StaticWebAssetsManifestName = "Microsoft.AspNetCore.StaticWebAssets.xml";
internal static void UseStaticWebAssets(IWebHostEnvironment environment)
{
using (var manifest = ResolveManifest(environment))
{
if (manifest != null)
{
UseStaticWebAssetsCore(environment, manifest);
}
}
}
internal static void UseStaticWebAssetsCore(IWebHostEnvironment environment, Stream manifest)
{
var staticWebAssetsFileProvider = new List<IFileProvider>();
var webRootFileProvider = environment.WebRootFileProvider;
var additionalFiles = StaticWebAssetsReader.Parse(manifest)
.Select(cr => new StaticWebAssetsFileProvider(cr.BasePath, cr.Path))
.OfType<IFileProvider>() // Upcast so we can insert on the resulting list.
.ToList();
if (additionalFiles.Count == 0)
{
return;
}
else
{
additionalFiles.Insert(0, webRootFileProvider);
environment.WebRootFileProvider = new CompositeFileProvider(additionalFiles);
}
}
internal static Stream ResolveManifest(IWebHostEnvironment environment)
{
// We plan to remove the embedded file resolution code path in
// a future preview.
Assembly assembly = null;
try
{
assembly = Assembly.Load(environment.ApplicationName);
}
catch (Exception)
{
}
if (assembly != null && assembly.GetManifestResourceNames().Any(a => a == StaticWebAssetsManifestName))
{
return assembly.GetManifestResourceStream(StaticWebAssetsManifestName);
}
else
{
// Fallback to physical file as we plan to use a file on disk instead of the embedded resource.
var filePath = Path.Combine(Path.GetDirectoryName(GetAssemblyLocation(assembly)), $"{environment.ApplicationName}.StaticWebAssets.xml");
if (File.Exists(filePath))
{
return File.OpenRead(filePath);
}
else
{
// A missing manifest might simply mean that the feature is not enabled, so we simply
// return early. Misconfigurations will be uncommon given that the entire process is automated
// at build time.
return null;
}
}
}
internal static string GetAssemblyLocation(Assembly assembly)
{
if (Uri.TryCreate(assembly.CodeBase, UriKind.Absolute, out var result) &&
result.IsFile && string.IsNullOrWhiteSpace(result.Fragment))
{
return result.LocalPath;
}
return assembly.Location;
}
}
}

View File

@ -0,0 +1,75 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml.Linq;
namespace Microsoft.AspNetCore
{
internal static class StaticWebAssetsReader
{
private const string ManifestRootElementName = "StaticWebAssets";
private const string VersionAttributeName = "Version";
private const string ContentRootElementName = "ContentRoot";
internal static IEnumerable<ContentRootMapping> Parse(Stream manifest)
{
var document = XDocument.Load(manifest);
if (!string.Equals(document.Root.Name.LocalName, ManifestRootElementName, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Invalid manifest format. Manifest root must be '{ManifestRootElementName}'");
}
var version = document.Root.Attribute(VersionAttributeName);
if (version == null)
{
throw new InvalidOperationException($"Invalid manifest format. Manifest root element must contain a version '{VersionAttributeName}' attribute");
}
if (version.Value != "1.0")
{
throw new InvalidOperationException($"Unknown manifest version. Manifest version must be '1.0'");
}
foreach (var element in document.Root.Elements())
{
if (!string.Equals(element.Name.LocalName, ContentRootElementName, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Invalid manifest format. Invalid element '{element.Name.LocalName}'. All {StaticWebAssetsLoader.StaticWebAssetsManifestName} child elements must be '{ContentRootElementName}' elements.");
}
if (!element.IsEmpty)
{
throw new InvalidOperationException($"Invalid manifest format. {ContentRootElementName} can't have content.");
}
var basePath = ParseRequiredAttribute(element, "BasePath");
var path = ParseRequiredAttribute(element, "Path");
yield return new ContentRootMapping(basePath, path);
}
}
private static string ParseRequiredAttribute(XElement element, string attributeName)
{
var attribute = element.Attribute(attributeName);
if (attribute == null)
{
throw new InvalidOperationException($"Invalid manifest format. Missing {attributeName} attribute in '{ContentRootElementName}' element.");
}
return attribute.Value;
}
internal readonly struct ContentRootMapping
{
public ContentRootMapping(string basePath, string path)
{
BasePath = basePath;
Path = path;
}
public string BasePath { get; }
public string Path { get; }
}
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Hosting;
namespace Microsoft.AspNetCore
{
/// <summary>
/// Extensions for configuring static web assets for development.
/// </summary>
public static class StaticWebAssetsWebHostBuilderExtensions
{
/// <summary>
/// Configures the <see cref="IWebHostEnvironment.WebRootFileProvider"/> to use static web assets
/// defined by referenced projects and packages.
/// </summary>
/// <param name="builder">The <see cref="IWebHostBuilder"/>.</param>
/// <returns>The <see cref="IWebHostBuilder"/>.</returns>
public static IWebHostBuilder UseStaticWebAssets(this IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configBuilder) =>
{
StaticWebAssetsLoader.UseStaticWebAssets(context.HostingEnvironment);
});
return builder;
}
}
}

View File

@ -206,6 +206,13 @@ namespace Microsoft.AspNetCore
internal static void ConfigureWebDefaults(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((ctx, cb) =>
{
if (ctx.HostingEnvironment.IsDevelopment())
{
StaticWebAssetsLoader.UseStaticWebAssets(ctx.HostingEnvironment);
}
});
builder.UseKestrel((builderContext, options) =>
{
options.Configure(builderContext.Configuration.GetSection("Kestrel"));

View File

@ -0,0 +1,3 @@
<StaticWebAssets Version="1.0">
<ContentRoot Path="/Path" BasePath="/BasePath" />
</StaticWebAssets>

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
@ -7,6 +7,8 @@
<ItemGroup>
<Reference Include="Microsoft.AspNetCore" />
<Reference Include="Microsoft.AspNetCore.TestHost" />
<EmbeddedResource Include="Microsoft.AspNetCore.TestHost.StaticWebAssets.xml" LogicalName="Microsoft.AspNetCore.StaticWebAssets.xml" />
<Content Include="Microsoft.AspNetCore.TestHost.StaticWebAssets.xml" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,88 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Runtime.InteropServices;
using Xunit;
namespace Microsoft.AspNetCore.Tests
{
public class StaticWebAssetsFileProviderTests
{
[Fact]
public void StaticWebAssetsFileProvider_ConstructorThrows_WhenPathIsNotFound()
{
// Arrange, Act & Assert
var provider = Assert.Throws<DirectoryNotFoundException>(() => new StaticWebAssetsFileProvider("/prefix", "/nonexisting"));
}
[Fact]
public void StaticWebAssetsFileProvider_Constructor_PrependsPrefixWithSlashIfMissing()
{
// Arrange & Act
var provider = new StaticWebAssetsFileProvider("_content", AppContext.BaseDirectory);
// Assert
Assert.Equal("/_content", provider.BasePath);
}
[Fact]
public void StaticWebAssetsFileProvider_Constructor_DoesNotPrependPrefixWithSlashIfPresent()
{
// Arrange & Act
var provider = new StaticWebAssetsFileProvider("/_content", AppContext.BaseDirectory);
// Assert
Assert.Equal("/_content", provider.BasePath);
}
[Fact]
public void GetFileInfo_DoesNotMatch_IncompletePrefixSegments()
{
// Arrange
var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var provider = new StaticWebAssetsFileProvider(
"_cont",
Path.GetDirectoryName(new Uri(typeof(StaticWebAssetsFileProviderTests).Assembly.CodeBase).LocalPath));
// Act
var file = provider.GetFileInfo("/_content/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml");
// Assert
Assert.False(file.Exists, "File exists");
}
[Fact]
public void GetFileInfo_Prefix_RespectsOsCaseSensitivity()
{
// Arrange
var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var provider = new StaticWebAssetsFileProvider(
"_content",
Path.GetDirectoryName(new Uri(typeof(StaticWebAssetsFileProviderTests).Assembly.CodeBase).LocalPath));
// Act
var file = provider.GetFileInfo("/_CONTENT/Microsoft.AspNetCore.TestHost.StaticWebAssets.xml");
// Assert
Assert.Equal(expectedResult, file.Exists);
}
[Fact]
public void GetDirectoryContents_Prefix_RespectsOsCaseSensitivity()
{
// Arrange
var expectedResult = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var provider = new StaticWebAssetsFileProvider(
"_content",
Path.GetDirectoryName(new Uri(typeof(StaticWebAssetsFileProviderTests).Assembly.CodeBase).LocalPath));
// Act
var directory = provider.GetDirectoryContents("/_CONTENT");
// Assert
Assert.Equal(expectedResult, directory.Exists);
}
}
}

View File

@ -0,0 +1,108 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.Hosting.Internal;
using Microsoft.Extensions.FileProviders;
using Xunit;
namespace Microsoft.AspNetCore.Tests
{
public class StaticWebAssetsLoaderTests
{
[Fact]
public void UseStaticWebAssetsCore_CreatesCompositeRoot_WhenThereAreContentRootsInTheManifest()
{
// Arrange
var manifestContent = @$"<StaticWebAssets Version=""1.0"">
<ContentRoot Path=""{AppContext.BaseDirectory}"" BasePath=""/BasePath"" />
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
var originalRoot = new NullFileProvider();
var environment = new HostingEnvironment()
{
WebRootFileProvider = originalRoot
};
// Act
StaticWebAssetsLoader.UseStaticWebAssetsCore(environment, manifest);
// Assert
var composite = Assert.IsType<CompositeFileProvider>(environment.WebRootFileProvider);
Assert.Equal(2, composite.FileProviders.Count());
Assert.Equal(originalRoot, composite.FileProviders.First());
}
[Fact]
public void UseStaticWebAssetsCore_DoesNothing_WhenManifestDoesNotContainEntries()
{
// Arrange
var manifestContent = @$"<StaticWebAssets Version=""1.0"">
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
var originalRoot = new NullFileProvider();
var environment = new HostingEnvironment()
{
WebRootFileProvider = originalRoot
};
// Act
StaticWebAssetsLoader.UseStaticWebAssetsCore(environment, manifest);
// Assert
Assert.Equal(originalRoot, environment.WebRootFileProvider);
}
[Fact]
public void ResolveManifest_FindsEmbeddedManifestProvider()
{
// Arrange
var expectedManifest = @"<StaticWebAssets Version=""1.0"">
<ContentRoot Path=""/Path"" BasePath=""/BasePath"" />
</StaticWebAssets>
";
var originalRoot = new NullFileProvider();
var environment = new HostingEnvironment()
{
ApplicationName = typeof(StaticWebAssetsReaderTests).Assembly.GetName().Name
};
// Act
var manifest = StaticWebAssetsLoader.ResolveManifest(environment);
// Assert
Assert.Equal(expectedManifest, new StreamReader(manifest).ReadToEnd());
}
[Fact]
public void ResolveManifest_ManifestFromFile()
{
// Arrange
var expectedManifest = @"<StaticWebAssets Version=""1.0"">
<ContentRoot Path=""/Path"" BasePath=""/BasePath"" />
</StaticWebAssets>
";
var environment = new HostingEnvironment()
{
ApplicationName = "Microsoft.AspNetCore.TestHost"
};
// Act
var manifest = StaticWebAssetsLoader.ResolveManifest(environment);
// Assert
Assert.Equal(expectedManifest, new StreamReader(manifest).ReadToEnd());
}
private Stream CreateManifest(string manifestContent)
{
return new MemoryStream(Encoding.UTF8.GetBytes(manifestContent));
}
}
}

View File

@ -0,0 +1,147 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
using Xunit;
namespace Microsoft.AspNetCore.Tests
{
public class StaticWebAssetsReaderTests
{
[Fact]
public void ParseManifest_ThrowsFor_EmptyManifest()
{
// Arrange
var manifestContent = @"";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<XmlException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Root element is missing.", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_UnknownRootElement()
{
// Arrange
var manifestContent = @"<Invalid />";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Invalid manifest", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_MissingVersion()
{
// Arrange
var manifestContent = @"<StaticWebAssets />";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Invalid manifest", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_UnknownVersion()
{
// Arrange
var manifestContent = @"<StaticWebAssets Version=""2.0""/>";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Unknown manifest version", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_InvalidStaticWebAssetsChildren()
{
// Arrange
var manifestContent = @"<StaticWebAssets Version=""1.0"">
<Invalid />
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Invalid manifest", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_MissingBasePath()
{
// Arrange
var manifestContent = @"<StaticWebAssets Version=""1.0"">
<ContentRoot Path=""/Path"" />
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Invalid manifest", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_MissingPath()
{
// Arrange
var manifestContent = @"<StaticWebAssets Version=""1.0"">
<ContentRoot BasePath=""/BasePath"" />
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Invalid manifest", exception.Message);
}
[Fact]
public void ParseManifest_ThrowsFor_ChildContentRootContent()
{
// Arrange
var manifestContent = @"<StaticWebAssets Version=""1.0"">
<ContentRoot Path=""/Path"" BasePath=""/BasePath"">
</ContentRoot>
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => StaticWebAssetsReader.Parse(manifest).ToArray());
Assert.StartsWith("Invalid manifest", exception.Message);
}
[Fact]
public void ParseManifest_ParsesManifest_WithSingleItem()
{
// Arrange
var manifestContent = @"<StaticWebAssets Version=""1.0"">
<ContentRoot Path=""/Path"" BasePath=""/BasePath"" />
</StaticWebAssets>";
var manifest = CreateManifest(manifestContent);
// Act
var mappings = StaticWebAssetsReader.Parse(manifest).ToArray();
// Assert
var mapping = Assert.Single(mappings);
Assert.Equal("/Path", mapping.Path);
Assert.Equal("/BasePath", mapping.BasePath);
}
private Stream CreateManifest(string manifestContent)
{
return new MemoryStream(Encoding.UTF8.GetBytes(manifestContent));
}
}
}