-
Notifications
You must be signed in to change notification settings - Fork 0
Custom Drivers
Any backend — S3, Azure Blob, SFTP, HTTP, a database — plugs into FileHub by deriving from three base types:
DirectoryEntryFileEntry-
IFileHub(factory that produces a rootDirectoryEntry)
Optionally implement IUrlAccessible on the file class for URL-reachable items.
using FileHub;
public interface IMyFileHub : IFileHub { }
public class MyFileHub : IMyFileHub
{
public DirectoryEntry Root { get; }
public MyFileHub() => Root = new MyDirectory("root", parent: null);
}
public class MyDirectory : DirectoryEntry
{
public override string Path { get; }
public override DirectoryEntry Parent { get; }
public override DateTime CreationTimeUtc { get; } = DateTime.UtcNow;
public override DateTime LastWriteTimeUtc => CreationTimeUtc;
public MyDirectory(string name, MyDirectory parent) : base(name, rootPath: null)
{
Parent = parent;
Path = parent == null ? name : System.IO.Path.Combine(parent.Path, name);
}
public override bool Exists() => true;
public override Task<bool> FileExistsAsync(string name, CancellationToken ct = default) => /* ... */;
public override Task<bool> DirectoryExistsAsync(string name, CancellationToken ct = default) => /* ... */;
public override async Task<FileEntry> CreateFileAsync(string name, CancellationToken ct = default)
{
ThrowIfReadOnly();
ValidateName(name);
// Single-arg CreateFile refuses an existing target: probe and throw
// FileAlreadyExistsException. The overwrite overload is what clobbers.
if (await FileExistsAsync(name, ct).ConfigureAwait(false))
throw new FileAlreadyExistsException(CombineChildPath(name));
await /* backend call */;
return new MyFile(this, name);
}
// + TryOpenFileAsync, CreateDirectoryAsync, TryOpenDirectoryAsync,
// DeleteAsync(recursive), DeleteAsync(name, recursive), GetFiles, GetDirectories
//
// Async is the source of truth: those are the abstract members. The base
// class provides every sync sibling (CreateFile, FileExists, Delete, ...)
// by bridging to your async implementation — override a sync method only
// when your backend is natively synchronous (see below). Enumeration
// (GetFiles/GetDirectories) is the exception: the abstract surface is the
// sync pull model, and the *Async enumerators wrap it unless you override
// them for native pagination.
}
public class MyFile : FileEntry
{
public override string Path => System.IO.Path.Combine(Parent.Path, Name);
public override DirectoryEntry Parent { get; }
public override long Length => /* ... */;
public override DateTime CreationTimeUtc { get; }
public override DateTime LastWriteTimeUtc => /* ... */;
public MyFile(DirectoryEntry parent, string name) : base(name) => Parent = parent;
public override bool Exists() => /* ... */;
public override Stream GetReadStream() => /* ... */;
public override Stream GetWriteStream(FileWriteOptions options = null) { ThrowIfReadOnly(); /* ... */ }
// + Delete, Rename, MoveTo
}-
ThrowIfReadOnly()is the first line of every write. That's howAsReadOnly()works — see Security. -
ValidateName(name)on every caller-supplied leaf name. Portable rule (PathUtil.ValidateName, identical on every OS): blocksnull, empty,.,.., path separators and control characters. Drivers backed by a real file system layerPathUtil.ValidateLocalNameon top for the OS-specific characters. -
Enforce the sandbox if your backend has an addressable namespace. Use
ResolveSafePath/EnsureWithinRootonDirectoryEntry, or an equivalent prefix check (seePathUtil.EnsureWithinRootPrefixin the core package). -
Don't leak SDK types. Wrap external SDK exceptions into
FileHubExceptionwhen they represent I/O failure; keep SDK types internal. -
Ship a marker interface —
public interface IMyFileHub : IFileHub { }— so DI consumers can bind to your driver without widening toIFileHub. -
Implement
FileExistsAsyncandDirectoryExistsAsyncseparately. Each abstract method should use the cheapest probe your backend offers (one call apiece is the target). Callers that want either combine the two themselves. -
Honour
overwriteif you override the copy/move fast path.FileEntry.CopyTo/MoveTotakebool overwrite = false; the base guards it viaDirectoryEntry.Exists/ExistsAsyncand throwsFileAlreadyExistsExceptionon an existing destination unlessoverwrite: true. If you override for a native same-backend path (server-side copy/rename), re-check the destination with anExistsprobe before the server op whenoverwriteisfalse— don't let the backend clobber silently. LikewiseRenamemust never overwrite: probe first and throwFileAlreadyExistsExceptionon a taken name. -
Optionally override
ExistsAsync(string name)/CombineChildPath. The baseExistsAsync(name)does a file-then-directory probe; if one backend request can answer "is anything at this name?" (e.g. a single LIST on object storage), override it. OverrideCombineChildPathwhen child paths don't use the driver-neutral/separator (the Local driver joins with the OS-native separator).
On DirectoryEntry the abstract surface is async — you implement DeleteAsync, CreateFileAsync, etc., and the base class supplies every sync sibling by bridging to them (thread-pool offload + block, deadlock-safe under a SynchronizationContext). Don't implement both rails independently:
public override async Task DeleteAsync(CancellationToken ct = default)
{
ThrowIfReadOnly();
await client.DeleteAsync(Key, ct).ConfigureAwait(false);
// base Delete() bridges here — nothing else to write
}Matches the pattern used by the S3/OCI/FTP drivers.
For a natively synchronous backend (in-process state, local file system), invert locally: put the logic in a sync override and implement the async abstract as a thin wrapper, skipping the bridge hop:
public override void Delete() { ThrowIfReadOnly(); /* sync backend call */ }
public override Task DeleteAsync(CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
Delete();
return Task.CompletedTask;
}Matches the Memory/Local drivers.
CreateDirectoryAsync / TryOpenDirectoryAsync receive the whole nested path ("a/b/c"). Split and validate it with PathUtil.SplitAndValidateSegments, then resolve it in the fewest operations your backend supports (one recursive mkdir, one PUT/LIST on object storage) — see the S3/OCI/FTP directories for reference implementations.
If your backend requires a network round-trip to stat a file (like OCI or FTP), don't do that I/O inside a property getter — it risks deadlock under a SynchronizationContext when a sync caller is nested in async code. Instead:
- Populate
_length/_creationTimeUtc/_lastWriteTimeUtcat construction time from data yourTryOpenFile/GetFilesalready fetched. - Update
_lengthfrom the write pipeline as bytes stream (seeFtpStream/OciObjectStream). - Implement
IRefreshableso callers who need authoritative state can ask explicitly.
public class MyFile : FileEntry, IRefreshable
{
private long _length;
public override long Length => _length; // pure getter
public void Refresh() => RefreshAsync().GetAwaiter().GetResult();
public async Task RefreshAsync(CancellationToken ct = default)
{
var stat = await client.StatAsync(Key, ct);
_length = stat.Size;
/* ... */
}
}Drivers whose state is already authoritative in process (Memory) or tracked by the OS (Local) don't need this — their getters read the live state directly.
If your backend has a native per-object metadata surface (content type, cache-control, user tags), wire it up by overriding the write stream (it receives the FileWriteOptions) and GetMetadataAsync on FileEntry. Drivers without such a surface silently ignore the options — never throw. Document the supported fields in your driver's README so callers know what round-trips.
-
Apply options on write — override
GetWriteStreamAsync(or the syncGetWriteStreamfor non-async backends); the other write methods —SetBytesAsync,SetTextAsync,CopyFromStreamAsync— funnel through it. Keep the options on the stream, not on the file, so an abandoned write can't leak forward:
public override Task<Stream> GetWriteStreamAsync(FileWriteOptions options = null, CancellationToken ct = default)
{
ThrowIfReadOnly();
ct.ThrowIfCancellationRequested();
return Task.FromResult<Stream>(new MyWriteStream(this, options)); // stream applies options on commit
}-
Return an immutable snapshot on read — override
GetMetadataAsyncto return aFileMetadata. Replace the cached snapshot wholesale on each refresh/write rather than mutating in place:
public override async Task<FileMetadata> GetMetadataAsync(CancellationToken ct = default)
{
if (!IsLoaded) await RefreshAsync(ct);
return _metadata; // immutable; safe to hand out directly
}-
(Optional) Expose backend-specific fields with subclasses — derive from
FileWriteOptionsfor write knobs and fromFileMetadatafor read-only typed fields, mirroringS3WriteOptions/AmazonS3FileMetadata. Downcast the incomingFileWriteOptionsin your write path. Drivers that don't support a field must ignore it silently — never throw.
If your backend lets you produce a directory handle without a server round-trip, override OpenOrCreateChildDirectoryAsync (the hook called by OpenDirectory(name, createIfNotExists: true) and the descent step of nested CreateFile/OpenFile) to skip the probe entirely. For files, override CreateFileAsync and TryOpenFileAsync to return a stub directly:
- The file stub starts with
Length = -1, default timestamps, empty metadata. - Writes materialise it;
Exists()triggers the first stat; reads from a never-materialised stub return 0 bytes. - Reading bytes from a missing stub silently returns 0 bytes —
Exists()is the contract for "is there really something here".
Pair this with ILazyLoad so consumers can detect unloaded handles:
public class MyFile : FileEntry, ILazyLoad
{
public bool IsLoaded { get; private set; }
public void Refresh() => RefreshAsync().GetAwaiter().GetResult();
public async Task RefreshAsync(CancellationToken ct = default) { /* stat + populate fields */; IsLoaded = true; }
}Flip IsLoaded to true after any of: a successful Refresh, an Exists() that hit and populated state, a successful write commit, or an entry resolved by TryOpenFile / OpenFile. The S3 and OCI drivers follow this contract.
public class MyFile : FileEntry, IUrlAccessible
{
public bool IsPublic => /* ... */;
public Uri GetPublicUrl() =>
IsPublic
? new Uri($"https://cdn.example.com/{Path}")
: throw new InvalidOperationException("Not public.");
public Uri GetSignedUrl(TimeSpan expiresIn)
=> GetSignedUrlAsync(expiresIn).GetAwaiter().GetResult();
public async Task<Uri> GetSignedUrlAsync(TimeSpan expiresIn, CancellationToken ct = default)
{
if (expiresIn <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(expiresIn));
return await signer.SignAsync(Path, expiresIn, ct);
}
}Define an internal interface for the operations your driver needs; depend on it from MyDirectory / MyFile; implement it twice (real SDK + in-memory fake). The OCI driver does this with IOciClient.
internal interface IMyBackendClient
{
Task<Stream> GetAsync(string key, CancellationToken ct);
Task PutAsync(string key, Stream body, CancellationToken ct);
Task DeleteAsync(string key, CancellationToken ct);
}Consumers register your driver with the standard helpers — no work on your side:
services.AddFileHub<IMyFileHub>(sp => new MyFileHub(config));See Dependency Injection for named hubs and lifetime control.
-
MyFileHub : IMyFileHub : IFileHubwith aRoot -
MyDirectory : DirectoryEntry— every abstract overridden -
MyFile : FileEntry— every abstract overridden -
ThrowIfReadOnly()on every mutation -
ValidateName(name)on every caller-supplied leaf - Sandbox enforcement where the backend has a path/key namespace
- SDK types/exceptions kept internal; wrapped into
FileHubExceptionwhen I/O-shaped - Async abstracts implemented against the backend's native rail (async for network-bound, sync-wrapped for in-process/local)
- Optional:
IRefreshableon file/directory classes that cache metadata - Optional: per-object metadata — apply
FileWriteOptionson the write path + overrideGetMetadataAsync - Optional:
ILazyLoadon file classes that can be returned in an unloaded state - Optional: override
OpenOrCreateChildDirectoryAsync(and return file stubs fromCreateFileAsync/TryOpenFileAsync) for zero-call open-or-create - Optional:
IUrlAccessibleon the file class - Optional:
Dispose()overridden for unmanaged resources
FileHub — unified file & directory API for .NET. Core is dependency-free; drivers are opt-in packages.