When using dependency injection for nearly everything it's good to have some file access abstraction. I find the idea of ASP.NET Core FileProvider nice but not sufficient for my needs so inspired by that I decided to create my own with some more functionality.
Interfaces
I have two interfaces that are called just like theirs but they have different members and also other names.
The first interface represents a single file or a directory.
[PublicAPI]
public interface IFileInfo : IEquatable<IFileInfo>, IEquatable<string>
{
[NotNull]
string Path { get; }
[NotNull]
string Name { get; }
bool Exists { get; }
long Length { get; }
DateTime ModifiedOn { get; }
bool IsDirectory { get; }
[NotNull]
Stream CreateReadStream();
}
The other interface allows me to perform four of the basic file/directory operations:
[PublicAPI]
public interface IFileProvider
{
[NotNull]
IFileInfo GetFileInfo([NotNull] string path);
[NotNull]
IFileInfo CreateDirectory([NotNull] string path);
[NotNull]
IFileInfo DeleteDirectory([NotNull] string path, bool recursive);
[NotNull]
Task<IFileInfo> CreateFileAsync([NotNull] string path, [NotNull] Stream data);
[NotNull]
IFileInfo DeleteFile([NotNull] string path);
}
On top of them I've build three providers:
PhysicalFileProvider
andPhysicalFileInfo
- used for operation on the physical driveEmbeddedFileProvider
andEmbeddedFileInfo
- used for reading of embedded resources (primarily for testing); internally, it automatically adds the root namespace of the specified assembly to the pathInMemoryFileProvider
andInMemoryFileInfo
- used for testing or runtime data
There is no GetDirectoryContents
API because this is what I have the DirectoryTree
for.
All providers use the same path schema, this is, with the backslash \
. This is also why the EmbeddedFileProvider
does some additional converting between the usual patch and the resource path which is separated by dots .
Implementations
So here they are, the three pairs, in the same order as the above list:
[PublicAPI]
public class PhysicalFileProvider : IFileProvider
{
public IFileInfo GetFileInfo(string path)
{
if (path == null) throw new ArgumentNullException(nameof(path));
return new PhysicalFileInfo(path);
}
public IFileInfo CreateDirectory(string path)
{
if (path == null) throw new ArgumentNullException(nameof(path));
if (Directory.Exists(path))
{
return new PhysicalFileInfo(path);
}
try
{
var newDirectory = Directory.CreateDirectory(path);
return new PhysicalFileInfo(newDirectory.FullName);
}
catch (Exception ex)
{
throw new CreateDirectoryException(path, ex);
}
}
public async Task<IFileInfo> CreateFileAsync(string path, Stream data)
{
try
{
using (var fileStream = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
{
await data.CopyToAsync(fileStream);
await fileStream.FlushAsync();
}
return new PhysicalFileInfo(path);
}
catch (Exception ex)
{
throw new CreateFileException(path, ex);
}
}
public IFileInfo DeleteFile(string path)
{
if (path == null) throw new ArgumentNullException(nameof(path));
try
{
File.Delete(path);
return new PhysicalFileInfo(path);
}
catch (Exception ex)
{
throw new DeleteFileException(path, ex);
}
}
public IFileInfo DeleteDirectory(string path, bool recursive)
{
try
{
Directory.Delete(path, recursive);
return new PhysicalFileInfo(path);
}
catch (Exception ex)
{
throw new DeleteDirectoryException(path, ex);
}
}
}
[PublicAPI]
internal class PhysicalFileInfo : IFileInfo
{
public PhysicalFileInfo([NotNull] string path) => Path = path ?? throw new ArgumentNullException(nameof(path));
#region IFileInfo
public string Path { get; }
public string Name => System.IO.Path.GetFileName(Path);
public bool Exists => File.Exists(Path) || Directory.Exists(Path);
public long Length => Exists && !IsDirectory ? new FileInfo(Path).Length : -1;
public DateTime ModifiedOn => !string.IsNullOrEmpty(Path) ? File.GetLastWriteTime(Path) : default;
public bool IsDirectory => Directory.Exists(Path);
public Stream CreateReadStream()
{
return
IsDirectory
? throw new InvalidOperationException($"Cannot open '{Path}' for reading because it's a directory.")
: Exists
? File.OpenRead(Path)
: throw new InvalidOperationException("Cannot open '{Path}' for reading because the file does not exist.");
}
#endregion
#region IEquatable<IFileInfo>
public override bool Equals(object obj) => obj is IFileInfo file && Equals(file);
public bool Equals(IFileInfo other) => FileInfoEqualityComparer.Default.Equals(other, this);
public bool Equals(string other) => FileInfoEqualityComparer.Default.Equals(other, Path);
public override int GetHashCode() => FileInfoEqualityComparer.Default.GetHashCode(this);
#endregion
}
public class EmbeddedFileProvider : IFileProvider
{
private readonly Assembly _assembly;
public EmbeddedFileProvider([NotNull] Assembly assembly)
{
_assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
BasePath = _assembly.GetName().Name.Replace('.', '\\');
}
public string BasePath { get; }
public IFileInfo GetFileInfo(string path)
{
if (path == null) throw new ArgumentNullException(nameof(path));
// Embedded resouce names are separated by '.' so replace the windows separator.
var fullName = Path.Combine(BasePath, path).Replace('\\', '.');
// Embedded resource names are case sensitive so find the actual name of the resource.
var actualName = _assembly.GetManifestResourceNames().FirstOrDefault(name => SoftString.Comparer.Equals(name, fullName));
var getManifestResourceStream = actualName is null ? default(Func<Stream>) : () => _assembly.GetManifestResourceStream(actualName);
return new EmbeddedFileInfo(UndoConvertPath(fullName), getManifestResourceStream);
}
// Convert path back to windows format but the last '.' - this is the file extension.
private static string UndoConvertPath(string path) => Regex.Replace(path, @"\.(?=.*?\.)", "\\");
public IFileInfo CreateDirectory(string path)
{
throw new NotSupportedException($"{nameof(EmbeddedFileProvider)} does not support directory creation.");
}
public IFileInfo DeleteDirectory(string path, bool recursive)
{
throw new NotSupportedException($"{nameof(EmbeddedFileProvider)} does not support directory deletion.");
}
public Task<IFileInfo> CreateFileAsync(string path, Stream data)
{
throw new NotSupportedException($"{nameof(EmbeddedFileProvider)} does not support file creation.");
}
public IFileInfo DeleteFile(string path)
{
throw new NotSupportedException($"{nameof(EmbeddedFileProvider)} does not support file deletion.");
}
}
internal class EmbeddedFileInfo : IFileInfo
{
private readonly Func<Stream> _getManifestResourceStream;
public EmbeddedFileInfo(string path, Func<Stream> getManifestResourceStream)
{
_getManifestResourceStream = getManifestResourceStream;
Path = path;
}
public string Path { get; }
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
public bool Exists => !(_getManifestResourceStream is null);
public long Length => _getManifestResourceStream()?.Length ?? -1;
public DateTime ModifiedOn { get; }
public bool IsDirectory => false;
// No protection necessary because there are no embedded directories.
public Stream CreateReadStream() => _getManifestResourceStream();
#region IEquatable<IFileInfo>
public override bool Equals(object obj) => obj is IFileInfo file && Equals(file);
public bool Equals(IFileInfo other) => FileInfoEqualityComparer.Default.Equals(other, this);
public bool Equals(string other) => FileInfoEqualityComparer.Default.Equals(other, Path);
public override int GetHashCode() => FileInfoEqualityComparer.Default.GetHashCode(this);
#endregion
}
public class InMemoryFileProvider : Dictionary<string, byte[]>, IFileProvider
{
private readonly ISet<IFileInfo> _files = new HashSet<IFileInfo>();
#region IFileProvider
public IFileInfo GetFileInfo(string path)
{
var file = _files.SingleOrDefault(f => FileInfoEqualityComparer.Default.Equals(f.Path, path));
return file ?? new InMemoryFileInfo(path, default(byte[]));
}
public IFileInfo CreateDirectory(string path)
{
path = path.TrimEnd('\\');
var newDirectory = new InMemoryFileInfo(path, _files.Where(f => f.Path.StartsWith(path)));
_files.Add(newDirectory);
return newDirectory;
}
public IFileInfo DeleteDirectory(string path, bool recursive)
{
return DeleteFile(path);
}
public Task<IFileInfo> CreateFileAsync(string path, Stream data)
{
var file = new InMemoryFileInfo(path, GetByteArray(data));
_files.Remove(file);
_files.Add(file);
return Task.FromResult<IFileInfo>(file);
byte[] GetByteArray(Stream stream)
{
using (var memoryStream = new MemoryStream())
{
stream.CopyTo(memoryStream);
return memoryStream.ToArray();
}
}
}
public IFileInfo DeleteFile(string path)
{
var fileToDelete = new InMemoryFileInfo(path, default(byte[]));
_files.Remove(fileToDelete);
return fileToDelete;
}
#endregion
}
internal class InMemoryFileInfo : IFileInfo
{
[CanBeNull]
private readonly byte[] _data;
[CanBeNull]
private readonly IEnumerable<IFileInfo> _files;
private InMemoryFileInfo([NotNull] string path)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
ModifiedOn = DateTime.UtcNow;
}
public InMemoryFileInfo([NotNull] string path, byte[] data)
: this(path)
{
_data = data;
Exists = !(data is null);
IsDirectory = false;
}
public InMemoryFileInfo([NotNull] string path, [NotNull] IEnumerable<IFileInfo> files)
: this(path)
{
_files = files ?? throw new ArgumentNullException(nameof(files));
Exists = true;
IsDirectory = true;
}
#region IFileInfo
public bool Exists { get; }
public long Length => IsDirectory ? throw new InvalidOperationException("Directories have no length.") : _data?.Length ?? -1;
public string Path { get; }
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
public DateTime ModifiedOn { get; }
public bool IsDirectory { get; }
public Stream CreateReadStream()
{
return
IsDirectory
? throw new InvalidOperationException("Cannot create read-stream for a directory.")
: Exists
// ReSharper disable once AssignNullToNotNullAttribute - this is never null because it's protected by Exists.
? new MemoryStream(_data)
: throw new InvalidOperationException("Cannot create a read-stream for a file that does not exist.");
}
#endregion
#region IEquatable<IFileInfo>
public override bool Equals(object obj) => obj is IFileInfo file && Equals(file);
public bool Equals(IFileInfo other) => FileInfoEqualityComparer.Default.Equals(other, this);
public bool Equals(string other) => FileInfoEqualityComparer.Default.Equals(other, Path);
public override int GetHashCode() => FileInfoEqualityComparer.Default.GetHashCode(this);
#endregion
}
Decorator for less typing
There is one more file provider. I use this to save some typing of paths. The RelativeFileProvider
adds its path in front of the other path if there is some root path that doesn't change.
public class RelativeFileProvider : IFileProvider
{
private readonly IFileProvider _fileProvider;
private readonly string _basePath;
public RelativeFileProvider([NotNull] IFileProvider fileProvider, [NotNull] string basePath)
{
_fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider));
_basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
}
public IFileInfo GetFileInfo(string path) => _fileProvider.GetFileInfo(CreateFullPath(path));
public IFileInfo CreateDirectory(string path) => _fileProvider.CreateDirectory(CreateFullPath(path));
public IFileInfo DeleteDirectory(string path, bool recursive) => _fileProvider.DeleteDirectory(CreateFullPath(path), recursive);
public Task<IFileInfo> CreateFileAsync(string path, Stream data) => _fileProvider.CreateFileAsync(CreateFullPath(path), data);
public IFileInfo DeleteFile(string path) => _fileProvider.DeleteFile(CreateFullPath(path));
private string CreateFullPath(string path) => Path.Combine(_basePath, path ?? throw new ArgumentNullException(nameof(path)));
}
Exceptions
The providers don't throw pure .NET exception because they aren't usually helpful. I wrap them in my own types:
public class CreateDirectoryException : Exception
{
public CreateDirectoryException(string path, Exception innerException)
: base($"Could not create directory: {path}", innerException)
{ }
}
public class CreateFileException : Exception
{
public CreateFileException(string path, Exception innerException)
: base($"Could not create file: {path}", innerException)
{ }
}
public class DeleteDirectoryException : Exception
{
public DeleteDirectoryException(string path, Exception innerException)
: base($"Could not delete directory: {path}", innerException)
{ }
}
public class DeleteFileException : Exception
{
public DeleteFileException(string path, Exception innerException)
: base($"Could not delete file: {path}", innerException)
{ }
}
Simple file search
There is one more provider that allows me to probe multiple providers. It supports only reading:
public class CompositeFileProvider : IFileProvider
{
private readonly IEnumerable<IFileProvider> _fileProviders;
public CompositeFileProvider(IEnumerable<IFileProvider> fileProviders)
{
_fileProviders = fileProviders;
}
public IFileInfo GetFileInfo(string path)
{
foreach (var fileProvider in _fileProviders)
{
var fileInfo = fileProvider.GetFileInfo(path);
if (fileInfo.Exists)
{
return fileInfo;
}
}
return new InMemoryFileInfo(path, new byte[0]);
}
public IFileInfo CreateDirectory(string path)
{
throw new NotSupportedException($"{nameof(CompositeFileProvider)} does not support directory creation.");
}
public IFileInfo DeleteDirectory(string path, bool recursive)
{
throw new NotSupportedException($"{nameof(CompositeFileProvider)} does not support directory deletion.");
}
public Task<IFileInfo> CreateFileAsync(string path, Stream data)
{
throw new NotSupportedException($"{nameof(CompositeFileProvider)} does not support file creation.");
}
public IFileInfo DeleteFile(string path)
{
throw new NotSupportedException($"{nameof(CompositeFileProvider)} does not support file deletion.");
}
}
Comparing files
The comparer for the IFileInfo
is very straightforward and compares the Path
property:
public class FileInfoEqualityComparer : IEqualityComparer<IFileInfo>, IEqualityComparer<string>
{
private static readonly IEqualityComparer PathComparer = StringComparer.OrdinalIgnoreCase;
[NotNull]
public static FileInfoEqualityComparer Default { get; } = new FileInfoEqualityComparer();
public bool Equals(IFileInfo x, IFileInfo y) => Equals(x?.Path, y?.Path);
public int GetHashCode(IFileInfo obj) => GetHashCode(obj.Path);
public bool Equals(string x, string y) => PathComparer.Equals(x, y);
public int GetHashCode(string obj) => PathComparer.GetHashCode(obj);
}
Example
As an example I use one of my tests that checks whether the relative and embedded files providers do their job correctly.
[TestClass]
public class RelativeFileProviderTest
{
[TestMethod]
public void GetFileInfo_DoesNotGetNonExistingEmbeddedFile()
{
var fileProvider =
new RelativeFileProvider(
new EmbeddedFileProvider(typeof(RelativeFileProviderTest).Assembly),
@"relative\path");
var file = fileProvider.GetFileInfo(@"file.ext");
Assert.IsFalse(file.Exists);
Assert.IsTrue(SoftString.Comparer.Equals(@"Reusable\Tests\relative\path\file.ext", file.Path));
}
}
Questions
Besides of the default question about can this be improved in anyway I have more:
- should I be concerned about thread-safty here? I didn't use any
lock
s but adding them isn't a big deal. Should I? Where would you add them? I guess creating files and directories could be good candidates, right? - should the
EmbeddedFileProvider
use theRelativeFileProvider
to add the assembly namespace to the path or should I leave it as is?