From 0ed4a4eba5b2a7f16841110e6ae2ec188d9b6dc7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 11 Dec 2017 14:01:34 +0000 Subject: [PATCH] Implement directory-capable InMemoryFileProvider --- Blazor.sln | 16 +- .../InMemoryDirectoryContents.cs | 44 ++++ .../FileProviders/InMemoryFileInfo.cs | 83 +++++++ .../FileProviders/InMemoryFileProvider.cs | 91 ++++++++ .../Microsoft.Blazor.Common.csproj | 11 + .../InMemoryFileProviderTest.cs | 211 ++++++++++++++++++ .../Microsoft.Blazor.Common.Test.csproj | 19 ++ 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Blazor.Common/FileProviders/InMemoryDirectoryContents.cs create mode 100644 src/Microsoft.Blazor.Common/FileProviders/InMemoryFileInfo.cs create mode 100644 src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs create mode 100644 src/Microsoft.Blazor.Common/Microsoft.Blazor.Common.csproj create mode 100644 test/Microsoft.Blazor.Common.Test/InMemoryFileProviderTest.cs create mode 100644 test/Microsoft.Blazor.Common.Test/Microsoft.Blazor.Common.Test.csproj diff --git a/Blazor.sln b/Blazor.sln index 23ed9142f4..5e3e58ccbc 100644 --- a/Blazor.sln +++ b/Blazor.sln @@ -44,7 +44,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StandaloneApp", "samples\St EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MonoSanity", "MonoSanity", "{2A076721-6081-4517-8329-B9E5110D6DAC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Blazor.Server.Test", "Microsoft.Blazor.Server.Test\Microsoft.Blazor.Server.Test.csproj", "{71AF445F-0903-4743-B047-44B3B2C19DC9}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Blazor.Server.Test", "Microsoft.Blazor.Server.Test\Microsoft.Blazor.Server.Test.csproj", "{71AF445F-0903-4743-B047-44B3B2C19DC9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Blazor.Common", "src\Microsoft.Blazor.Common\Microsoft.Blazor.Common.csproj", "{21EF76A4-63CC-455D-907C-F86C9E442CEC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Blazor.Common.Test", "test\Microsoft.Blazor.Common.Test\Microsoft.Blazor.Common.Test.csproj", "{7F0BF3EA-6985-49F6-8070-0BBA41448BB0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -108,6 +112,14 @@ Global {71AF445F-0903-4743-B047-44B3B2C19DC9}.Debug|Any CPU.Build.0 = Debug|Any CPU {71AF445F-0903-4743-B047-44B3B2C19DC9}.Release|Any CPU.ActiveCfg = Release|Any CPU {71AF445F-0903-4743-B047-44B3B2C19DC9}.Release|Any CPU.Build.0 = Release|Any CPU + {21EF76A4-63CC-455D-907C-F86C9E442CEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21EF76A4-63CC-455D-907C-F86C9E442CEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21EF76A4-63CC-455D-907C-F86C9E442CEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21EF76A4-63CC-455D-907C-F86C9E442CEC}.Release|Any CPU.Build.0 = Release|Any CPU + {7F0BF3EA-6985-49F6-8070-0BBA41448BB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F0BF3EA-6985-49F6-8070-0BBA41448BB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F0BF3EA-6985-49F6-8070-0BBA41448BB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F0BF3EA-6985-49F6-8070-0BBA41448BB0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -130,6 +142,8 @@ Global {B241434A-1642-44CC-AE9A-2012B5C5BD02} = {F5FDD4E5-6A52-4A86-BE5E-5E42CB1DC8DA} {2A076721-6081-4517-8329-B9E5110D6DAC} = {F5FDD4E5-6A52-4A86-BE5E-5E42CB1DC8DA} {71AF445F-0903-4743-B047-44B3B2C19DC9} = {ADA3AE29-F6DE-49F6-8C7C-B321508CAE8E} + {21EF76A4-63CC-455D-907C-F86C9E442CEC} = {B867E038-B3CE-43E3-9292-61568C46CDEB} + {7F0BF3EA-6985-49F6-8070-0BBA41448BB0} = {ADA3AE29-F6DE-49F6-8C7C-B321508CAE8E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {504DA352-6788-4DC0-8705-82167E72A4D3} diff --git a/src/Microsoft.Blazor.Common/FileProviders/InMemoryDirectoryContents.cs b/src/Microsoft.Blazor.Common/FileProviders/InMemoryDirectoryContents.cs new file mode 100644 index 0000000000..a482ccb314 --- /dev/null +++ b/src/Microsoft.Blazor.Common/FileProviders/InMemoryDirectoryContents.cs @@ -0,0 +1,44 @@ +// 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.Linq; +using Microsoft.Extensions.FileProviders; +using System.Collections.Generic; +using System.Collections; +using System; + +namespace Microsoft.Blazor.Internal.Common.FileProviders +{ + internal class InMemoryDirectoryContents : IDirectoryContents + { + private readonly Dictionary _names; // Just to ensure there are no duplicate names + private readonly List _contents; + + public InMemoryDirectoryContents(IEnumerable contents) + { + if (contents != null) + { + _contents = contents.ToList(); + _names = _contents.ToDictionary(item => item.Name, item => (object)null); + } + } + + public bool Exists => _contents != null; + + public IEnumerator GetEnumerator() => GetEnumeratorIfExists(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumeratorIfExists(); + + internal void AddItem(IFileInfo item) + { + _names.Add(item.Name, null); // Asserts uniqueness + _contents.Add(item); + } + internal bool ContainsName(string name) + => _names.ContainsKey(name); + + private IEnumerator GetEnumeratorIfExists() => Exists + ? _contents.GetEnumerator() + : throw new InvalidOperationException("The directory does not exist"); + } +} diff --git a/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileInfo.cs b/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileInfo.cs new file mode 100644 index 0000000000..731ea17005 --- /dev/null +++ b/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileInfo.cs @@ -0,0 +1,83 @@ +// 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.IO; +using System; + +namespace Microsoft.Blazor.Internal.Common.FileProviders +{ + internal class InMemoryFileInfo : IFileInfo + { + private readonly bool _exists; + private readonly bool _isDirectory; + private readonly byte[] _fileData; + private readonly string _name; + private readonly string _physicalPath; + private readonly DateTimeOffset _lastModified; + + public static IFileInfo ForExistingDirectory(string path) + => new InMemoryFileInfo(path, isDir: true, exists: true); + + public static IFileInfo ForExistingFile(string path, Stream dataStream, DateTimeOffset lastModified) + => new InMemoryFileInfo(path, isDir: false, exists: true, dataStream: dataStream, lastModified: lastModified); + + public static IFileInfo ForNonExistingFile(string path) + => new InMemoryFileInfo(path, isDir: false, exists: false); + + private InMemoryFileInfo(string physicalPath, bool isDir, bool exists, Stream dataStream = null, DateTimeOffset lastModified = default(DateTimeOffset)) + { + _exists = exists; + _isDirectory = isDir; + _name = Path.GetFileName(physicalPath); + _physicalPath = physicalPath ?? throw new ArgumentNullException(nameof(physicalPath)); + _lastModified = lastModified; + + if (_exists) + { + if (!_physicalPath.StartsWith(InMemoryFileProvider.DirectorySeparatorChar)) + { + throw new ArgumentException($"Must start with '{InMemoryFileProvider.DirectorySeparatorChar}'", + nameof(physicalPath)); + } + + if (_physicalPath.EndsWith(InMemoryFileProvider.DirectorySeparatorChar)) + { + throw new ArgumentException($"Must not end with '{InMemoryFileProvider.DirectorySeparatorChar}'", + nameof(physicalPath)); + } + } + + if (dataStream != null) + { + using (var ms = new MemoryStream()) + { + dataStream.CopyTo(ms); + _fileData = ms.ToArray(); + } + } + } + + public bool Exists => _exists; + + public long Length => Exists && !IsDirectory + ? _fileData.Length + : throw new InvalidOperationException(IsDirectory + ? "The item is a directory." + : "The item does not exist."); + + public string PhysicalPath => _physicalPath; + + public string Name => _name; + + public DateTimeOffset LastModified => _lastModified; + + public bool IsDirectory => _isDirectory; + + public Stream CreateReadStream() => Exists && !IsDirectory + ? new MemoryStream(_fileData) + : throw new InvalidOperationException(IsDirectory + ? "The item is a directory." + : "The item does not exist."); + } +} diff --git a/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs b/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs new file mode 100644 index 0000000000..5bbb163c54 --- /dev/null +++ b/src/Microsoft.Blazor.Common/FileProviders/InMemoryFileProvider.cs @@ -0,0 +1,91 @@ +// 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 Microsoft.Extensions.Primitives; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.Blazor.Internal.Common.FileProviders +{ + public class InMemoryFileProvider : IFileProvider + { + // Since the IFileProvider APIs don't include any way of asking for parent or + // child directories, it's efficient to store everything by full path + private readonly IDictionary _filesByFullPath; + private readonly IDictionary _directoriesByFullPath; + + // It's convenient to use forward slash, because it matches URL conventions + public const char DirectorySeparatorChar = '/'; + + public InMemoryFileProvider(IEnumerable<(string, Stream)> contents) + { + _filesByFullPath = contents + .Select(path => InMemoryFileInfo.ForExistingFile(path.Item1, path.Item2, DateTime.Now)) + .ToDictionary( + fileInfo => fileInfo.PhysicalPath, + fileInfo => (IFileInfo)fileInfo); + + _directoriesByFullPath = _filesByFullPath.Values + .GroupBy(file => GetDirectoryName(file.PhysicalPath)) + .ToDictionary( + group => group.Key, + group => new InMemoryDirectoryContents(group)); + + foreach (var dirToInsert in _directoriesByFullPath.Keys.ToList()) + { + AddSubdirectoryEntry(dirToInsert); + } + } + + private void AddSubdirectoryEntry(string dirPath) + { + // If this is the root directory, there's no parent + if (dirPath.Length == 0) + { + return; + } + + // Ensure parent directory exists + var parentDirPath = GetDirectoryName(dirPath); + if (!_directoriesByFullPath.ContainsKey(parentDirPath)) + { + _directoriesByFullPath.Add( + parentDirPath, + new InMemoryDirectoryContents(Enumerable.Empty())); + } + + var parentDir = _directoriesByFullPath[parentDirPath]; + if (!parentDir.ContainsName(Path.GetFileName(dirPath))) + { + parentDir.AddItem( + InMemoryFileInfo.ForExistingDirectory(dirPath)); + } + + // Doing this recursively creates all ancestor directories + AddSubdirectoryEntry(parentDirPath); + } + + private static string GetDirectoryName(string fullPath) + => fullPath.Substring(0, fullPath.LastIndexOf(DirectorySeparatorChar)); + + public IDirectoryContents GetDirectoryContents(string subpath) + => _directoriesByFullPath.TryGetValue(StripTrailingSeparator(subpath), out var result) + ? result + : new InMemoryDirectoryContents(null); + + public IFileInfo GetFileInfo(string subpath) + => _filesByFullPath.TryGetValue(subpath, out var fileInfo) + ? fileInfo + : InMemoryFileInfo.ForNonExistingFile(subpath); + + public IChangeToken Watch(string filter) + => throw new NotImplementedException(); + + private string StripTrailingSeparator(string path) => path.EndsWith(DirectorySeparatorChar) + ? path.Substring(0, path.Length - 1) + : path; + } +} diff --git a/src/Microsoft.Blazor.Common/Microsoft.Blazor.Common.csproj b/src/Microsoft.Blazor.Common/Microsoft.Blazor.Common.csproj new file mode 100644 index 0000000000..a9519032ba --- /dev/null +++ b/src/Microsoft.Blazor.Common/Microsoft.Blazor.Common.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp2.0 + + + + + + + diff --git a/test/Microsoft.Blazor.Common.Test/InMemoryFileProviderTest.cs b/test/Microsoft.Blazor.Common.Test/InMemoryFileProviderTest.cs new file mode 100644 index 0000000000..9e4d3579e9 --- /dev/null +++ b/test/Microsoft.Blazor.Common.Test/InMemoryFileProviderTest.cs @@ -0,0 +1,211 @@ +// 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 System; +using System.IO; +using System.Text; +using Xunit; + +namespace Microsoft.Blazor.Common.Test +{ + public class InMemoryFileProviderTest + { + private (string, Stream) TestItem(string name) => TestItem(name, Array.Empty()); + private (string, Stream) TestItem(string name, byte[] data) => (name, new MemoryStream(data)); + + [Fact] + public void RequiresPathsToStartWithSlash() + { + Assert.Throws(() => + { + new InMemoryFileProvider(new[] { TestItem("item") }); + }); + } + + [Fact] + public void RequiresPathsNotToEndWithSlash() + { + Assert.Throws(() => + { + new InMemoryFileProvider(new[] { TestItem("/item/") }); + }); + } + + [Fact] + public void ReturnsFileInfosForExistingFiles() + { + // Arrange + var instance = new InMemoryFileProvider(new[] + { + TestItem("/dirA/item", Encoding.UTF8.GetBytes("Contents of /dirA/item")), + TestItem("/dirB/item", Encoding.UTF8.GetBytes("Contents of /dirB/item")) + }); + + // Act + var dirAItem = instance.GetFileInfo("/dirA/item"); + var dirBItem = instance.GetFileInfo("/dirB/item"); + + // Assert + Assert.Equal( + "Contents of /dirA/item", + new StreamReader(dirAItem.CreateReadStream()).ReadToEnd()); + Assert.Equal( + "Contents of /dirB/item", + new StreamReader(dirBItem.CreateReadStream()).ReadToEnd()); + Assert.True(dirAItem.Exists); + Assert.False(dirAItem.IsDirectory); + Assert.True((DateTime.Now - dirAItem.LastModified).TotalDays < 1); // Exact behaviour doesn't need to be defined (at least not yet) but it should be a valid date + Assert.Equal(22, dirAItem.Length); + Assert.Equal("item", dirAItem.Name); + Assert.Equal("/dirA/item", dirAItem.PhysicalPath); + } + + [Fact] + public void ReturnsFileInfosForNonExistingFiles() + { + // Arrange + var instance = new InMemoryFileProvider(new[] { TestItem("/dirA/item") }); + + // Act + var mismatchedCaseItem = instance.GetFileInfo("/dira/item"); + var dirBItem = instance.GetFileInfo("/dirB/item"); + + // Assert + Assert.False(mismatchedCaseItem.Exists); + Assert.False(dirBItem.Exists); + Assert.False(dirBItem.IsDirectory); + Assert.Equal("item", dirBItem.Name); + Assert.Equal("/dirB/item", dirBItem.PhysicalPath); + } + + [Fact] + public void ReturnsDirectoryContentsForExistingDirectory() + { + // Arrange + var instance = new InMemoryFileProvider(new[] + { + TestItem("/dir/subdir/item1"), + TestItem("/dir/subdir/item2"), + TestItem("/dir/otherdir/item3") + }); + + // Act + var contents = instance.GetDirectoryContents("/dir/subdir"); + + // Assert + Assert.True(contents.Exists); + Assert.Collection(contents, + item => Assert.Equal("/dir/subdir/item1", item.PhysicalPath), + item => Assert.Equal("/dir/subdir/item2", item.PhysicalPath)); + } + + [Fact] + public void EmptyStringAndSlashAreBothInterpretedAsRootDir() + { + // Technically this test duplicates checking the behavior asserted + // previously (i.e., trailing slashes are ignored), but it's worth + // checking that nothing bad happens when the path is an empty string + + // Arrange + var instance = new InMemoryFileProvider(new[] { TestItem("/item") }); + + // Act/Assert + Assert.Collection(instance.GetDirectoryContents(string.Empty), + item => Assert.Equal("/item", item.PhysicalPath)); + Assert.Collection(instance.GetDirectoryContents("/"), + item => Assert.Equal("/item", item.PhysicalPath)); + } + + [Fact] + public void ReturnsDirectoryContentsIfGivenPathEndsWithSlash() + { + // Arrange + var instance = new InMemoryFileProvider(new[] { TestItem("/dir/subdir/item1") }); + + // Act + var contents = instance.GetDirectoryContents("/dir/subdir/"); + + // Assert + Assert.True(contents.Exists); + Assert.Collection(contents, + item => Assert.Equal("/dir/subdir/item1", item.PhysicalPath)); + } + + [Fact] + public void ReturnsDirectoryContentsForNonExistingDirectory() + { + // Arrange + var instance = new InMemoryFileProvider(new[] { TestItem("/dir/subdir/item1") }); + + // Act + var contents = instance.GetDirectoryContents("/dir/otherdir"); + + // Assert + Assert.False(contents.Exists); + Assert.Throws(() => contents.GetEnumerator()); + } + + [Fact] + public void IncludesSubdirectoriesInDirectoryContents() + { + // Arrange + var instance = new InMemoryFileProvider(new[] { + TestItem("/dir/sub/item1"), + TestItem("/dir/sub/item2"), + TestItem("/dir/sub2/item"), + TestItem("/unrelated/item") + }); + + // Act + var contents = instance.GetDirectoryContents("/dir"); + + // Assert + Assert.True(contents.Exists); + Assert.Collection(contents, + item => + { + // For this example, verify all properties. Don't need to do this for all examples. + Assert.True(item.Exists); + Assert.True(item.IsDirectory); + Assert.Equal(default(DateTimeOffset), item.LastModified); + Assert.Throws(() => item.Length); + Assert.Throws(() => item.CreateReadStream()); + Assert.Equal("sub", item.Name); + Assert.Equal("/dir/sub", item.PhysicalPath); + }, + item => + { + Assert.Equal("/dir/sub2", item.PhysicalPath); + Assert.True(item.IsDirectory); + }); + } + + [Fact] + public void HasAllAncestorDirectoriesForDirectory() + { + // Arrange + var instance = new InMemoryFileProvider(new[] { TestItem("/a/b/c") }); + + // Act/Assert + Assert.Collection(instance.GetDirectoryContents("/"), + item => + { + Assert.Equal("/a", item.PhysicalPath); + Assert.True(item.IsDirectory); + }); + Assert.Collection(instance.GetDirectoryContents("/a"), + item => + { + Assert.Equal("/a/b", item.PhysicalPath); + Assert.True(item.IsDirectory); + }); + Assert.Collection(instance.GetDirectoryContents("/a/b"), + item => + { + Assert.Equal("/a/b/c", item.PhysicalPath); + Assert.False(item.IsDirectory); + }); + } + } +} diff --git a/test/Microsoft.Blazor.Common.Test/Microsoft.Blazor.Common.Test.csproj b/test/Microsoft.Blazor.Common.Test/Microsoft.Blazor.Common.Test.csproj new file mode 100644 index 0000000000..54c8296c49 --- /dev/null +++ b/test/Microsoft.Blazor.Common.Test/Microsoft.Blazor.Common.Test.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp2.0 + + false + + + + + + + + + + + + +