Implement directory-capable InMemoryFileProvider

This commit is contained in:
Steve Sanderson 2017-12-11 14:01:34 +00:00
parent c439787ab5
commit 0ed4a4eba5
7 changed files with 474 additions and 1 deletions

View File

@ -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}

View File

@ -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<string, object> _names; // Just to ensure there are no duplicate names
private readonly List<IFileInfo> _contents;
public InMemoryDirectoryContents(IEnumerable<IFileInfo> contents)
{
if (contents != null)
{
_contents = contents.ToList();
_names = _contents.ToDictionary(item => item.Name, item => (object)null);
}
}
public bool Exists => _contents != null;
public IEnumerator<IFileInfo> 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<IFileInfo> GetEnumeratorIfExists() => Exists
? _contents.GetEnumerator()
: throw new InvalidOperationException("The directory does not exist");
}
}

View File

@ -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.");
}
}

View File

@ -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<string, IFileInfo> _filesByFullPath;
private readonly IDictionary<string, InMemoryDirectoryContents> _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<IFileInfo>()));
}
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;
}
}

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileProviders.Abstractions" Version="2.0.0" />
</ItemGroup>
</Project>

View File

@ -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<byte>());
private (string, Stream) TestItem(string name, byte[] data) => (name, new MemoryStream(data));
[Fact]
public void RequiresPathsToStartWithSlash()
{
Assert.Throws<ArgumentException>(() =>
{
new InMemoryFileProvider(new[] { TestItem("item") });
});
}
[Fact]
public void RequiresPathsNotToEndWithSlash()
{
Assert.Throws<ArgumentException>(() =>
{
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => item.Length);
Assert.Throws<InvalidOperationException>(() => 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);
});
}
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.5.0" />
<PackageReference Include="xunit" Version="2.3.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Blazor.Common\Microsoft.Blazor.Common.csproj" />
</ItemGroup>
</Project>