From a81ad1830f3bd5fe2de178352bab653148d38daa Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Dec 2017 14:31:55 +0000 Subject: [PATCH] Implement CompositeMountedFileProvider --- .../CompositeMountedFileProvider.cs | 87 +++++++++ .../FileProviders/InMemoryFileProvider.cs | 9 +- .../CompositeMountedFileProviderTest.cs | 170 ++++++++++++++++++ 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.Blazor.Common/FileProviders/CompositeMountedFileProvider.cs create mode 100644 test/Microsoft.Blazor.Common.Test/CompositeMountedFileProviderTest.cs diff --git a/src/Microsoft.Blazor.Common/FileProviders/CompositeMountedFileProvider.cs b/src/Microsoft.Blazor.Common/FileProviders/CompositeMountedFileProvider.cs new file mode 100644 index 0000000000..784cd281d7 --- /dev/null +++ b/src/Microsoft.Blazor.Common/FileProviders/CompositeMountedFileProvider.cs @@ -0,0 +1,87 @@ +// 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.Extensions.FileProviders; +using System.Collections.Generic; +using System.Linq; +using System; +using System.IO; + +namespace Microsoft.Blazor.Internal.Common.FileProviders +{ + // This is like a CompositeFileProvider, except that: + // (a) the child providers can be mounted at non-root locations + // (b) the set of contents is immutable and fully indexed at construction time + // so that all subsequent reads are "O(dictionary lookup)" time + public class CompositeMountedFileProvider : InMemoryFileProvider + { + public CompositeMountedFileProvider(IEnumerable<(string, IFileProvider)> providers) + : base(GetCompositeContents(providers)) + { + } + + private static IEnumerable GetCompositeContents( + IEnumerable<(string, IFileProvider)> providers) + { + return providers + .Select(pair => new { MountPoint = pair.Item1, Files = ReadAllFiles(pair.Item2, string.Empty) }) + .SelectMany(info => info.Files.Select(file => (IFileInfo)new MountedFileInfo(info.MountPoint, file))); + } + + private static IEnumerable ReadAllFiles(IFileProvider provider, string subpath) + { + return provider.GetDirectoryContents(subpath).SelectMany( + item => item.IsDirectory + ? ReadAllFiles(provider, item.PhysicalPath) + : new[] { item }); + } + + private class MountedFileInfo : IFileInfo + { + private readonly IFileInfo _file; + + public MountedFileInfo(string mountPoint, IFileInfo file) + { + _file = file; + + if (!file.PhysicalPath.StartsWith('/')) + { + throw new ArgumentException($"For mounted files, {nameof(file.PhysicalPath)} must start with '/'. Value supplied: '{file.PhysicalPath}'."); + } + + if (!mountPoint.StartsWith('/')) + { + throw new ArgumentException("The path must start with '/'", nameof(mountPoint)); + } + + if (mountPoint == "/") + { + PhysicalPath = file.PhysicalPath; + } + else + { + if (mountPoint.EndsWith('/')) + { + throw new ArgumentException("Non-root paths must not end with '/'", nameof(mountPoint)); + } + + PhysicalPath = mountPoint + file.PhysicalPath; + } + } + + public bool Exists => _file.Exists; + + public long Length => _file.Length; + + public string PhysicalPath { get; } + + public string Name => _file.Name; + + public DateTimeOffset LastModified => _file.LastModified; + + public bool IsDirectory => _file.IsDirectory; + + public Stream CreateReadStream() => _file.CreateReadStream(); + } + } +} diff --git a/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs b/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs index 5bbb163c54..eb5ec1868a 100644 --- a/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs +++ b/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs @@ -20,10 +20,15 @@ namespace Microsoft.Blazor.Internal.Common.FileProviders // It's convenient to use forward slash, because it matches URL conventions public const char DirectorySeparatorChar = '/'; - public InMemoryFileProvider(IEnumerable<(string, Stream)> contents) + public InMemoryFileProvider(IEnumerable<(string, Stream)> contents) : this( + contents.Select(pair => InMemoryFileInfo + .ForExistingFile(pair.Item1, pair.Item2, DateTime.Now))) + { + } + + public InMemoryFileProvider(IEnumerable contents) { _filesByFullPath = contents - .Select(path => InMemoryFileInfo.ForExistingFile(path.Item1, path.Item2, DateTime.Now)) .ToDictionary( fileInfo => fileInfo.PhysicalPath, fileInfo => (IFileInfo)fileInfo); diff --git a/test/Microsoft.Blazor.Common.Test/CompositeMountedFileProviderTest.cs b/test/Microsoft.Blazor.Common.Test/CompositeMountedFileProviderTest.cs new file mode 100644 index 0000000000..13fbf6d4fe --- /dev/null +++ b/test/Microsoft.Blazor.Common.Test/CompositeMountedFileProviderTest.cs @@ -0,0 +1,170 @@ +// 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.Blazor.Internal.Common.FileProviders; +using Microsoft.Extensions.FileProviders; +using System; +using System.IO; +using System.Linq; +using System.Text; +using Xunit; + +namespace Microsoft.Blazor.Common.Test +{ + public class CompositeMountedFileProviderTest + { + private (string, Stream) TestItem(string name) => TestItem(name, Array.Empty()); + private (string, Stream) TestItem(string name, string data) => TestItem(name, Encoding.UTF8.GetBytes(data)); + private (string, Stream) TestItem(string name, byte[] data) => (name, new MemoryStream(data)); + private IFileProvider TestFileProvider(params string[] paths) => new InMemoryFileProvider(paths.Select(TestItem)); + + [Fact] + public void MountPointsMustStartWithSlash() + { + Assert.Throws(() => + { + new CompositeMountedFileProvider(new[] + { + ("test", TestFileProvider("/something.txt")) + }); + }); + } + + [Fact] + public void NonRootMountPointsMustNotEndWithSlash() + { + Assert.Throws(() => + { + new CompositeMountedFileProvider(new[] + { + ("/test/", TestFileProvider("/something.txt")) + }); + }); + } + + [Fact] + public void MountedFilePathsMustStartWithSlash() + { + Assert.Throws(() => + { + new CompositeMountedFileProvider(new[] + { + ("/test", TestFileProvider("something.txt")) + }); + }); + } + + [Fact] + public void CanMountFileProviderAtRoot() + { + // Arrange + IFileProvider childProvider = new InMemoryFileProvider(new[] + { + TestItem("/rootitem.txt", "Root item contents"), + TestItem("/subdir/another", "Another test item"), + }); + var instance = new CompositeMountedFileProvider(new[] + { + ("/", childProvider) + }); + + // Act + var rootContents = instance.GetDirectoryContents(string.Empty); + var subdirContents = instance.GetDirectoryContents("/subdir"); + + // Assert + Assert.Collection(rootContents, + item => + { + Assert.Equal("/rootitem.txt", item.PhysicalPath); + Assert.False(item.IsDirectory); + Assert.Equal("Root item contents", new StreamReader(item.CreateReadStream()).ReadToEnd()); + }, + item => + { + Assert.Equal("/subdir", item.PhysicalPath); + Assert.True(item.IsDirectory); + }); + + Assert.Collection(subdirContents, + item => + { + Assert.Equal("/subdir/another", item.PhysicalPath); + Assert.False(item.IsDirectory); + Assert.Equal("Another test item", new StreamReader(item.CreateReadStream()).ReadToEnd()); + }); + } + + [Fact] + public void CanMountFileProvidersAtSubPaths() + { + // Arrange + var instance = new CompositeMountedFileProvider(new[] + { + ("/dir", TestFileProvider("/first", "/A/second", "/A/third")), + ("/dir/sub", TestFileProvider("/X", "/B/Y", "/B/Z")), + ("/other", TestFileProvider("/final")), + }); + + // Act + var rootContents = instance.GetDirectoryContents("/"); + var rootDirContents = instance.GetDirectoryContents("/dir"); + var rootDirAContents = instance.GetDirectoryContents("/dir/A"); + var rootDirSubContents = instance.GetDirectoryContents("/dir/sub"); + var rootDirSubBContents = instance.GetDirectoryContents("/dir/sub/B"); + var otherContents = instance.GetDirectoryContents("/other"); + + // Assert + Assert.Collection(rootContents, + item => Assert.Equal("/dir", item.PhysicalPath), + item => Assert.Equal("/other", item.PhysicalPath)); + Assert.Collection(rootDirContents, + item => Assert.Equal("/dir/first", item.PhysicalPath), + item => Assert.Equal("/dir/A", item.PhysicalPath), + item => Assert.Equal("/dir/sub", item.PhysicalPath)); + Assert.Collection(rootDirAContents, + item => Assert.Equal("/dir/A/second", item.PhysicalPath), + item => Assert.Equal("/dir/A/third", item.PhysicalPath)); + Assert.Collection(rootDirSubContents, + item => Assert.Equal("/dir/sub/X", item.PhysicalPath), + item => Assert.Equal("/dir/sub/B", item.PhysicalPath)); + Assert.Collection(rootDirSubBContents, + item => Assert.Equal("/dir/sub/B/Y", item.PhysicalPath), + item => Assert.Equal("/dir/sub/B/Z", item.PhysicalPath)); + Assert.Collection(otherContents, + item => Assert.Equal("/other/final", item.PhysicalPath)); + } + + [Fact] + public void CanMountMultipleFileProvidersAtSameLocation() + { + // Arrange + var instance = new CompositeMountedFileProvider(new[] + { + ("/dir", TestFileProvider("/first")), + ("/dir", TestFileProvider("/second")) + }); + + // Act + var contents = instance.GetDirectoryContents("/dir"); + + // Assert + Assert.Collection(contents, + item => Assert.Equal("/dir/first", item.PhysicalPath), + item => Assert.Equal("/dir/second", item.PhysicalPath)); + } + + [Fact] + public void DisallowsOverlappingFiles() + { + Assert.Throws(() => + { + new CompositeMountedFileProvider(new[] + { + ("/dir", TestFileProvider("/file")), + ("/", TestFileProvider("/dir/file")) + }); + }); + } + } +}