This guide walks you through creating a custom transport extension for QWKSync.NET.
Transport extensions allow you to add support for new protocols (HTTP, FTP, SFTP, etc.) or custom endpoints. A transport extension consists of:
ITransport implementation — performs the actual file operationsITransportFactory implementation — creates transport instancesCreate a new .NET class library project for your transport extension:
dotnet new classlib -n QwkSync.YourTransport
Add a reference to the QWKSync.NET library:
dotnet add reference ../src/QwkSync/QwkSync.csproj
Or add it to your .csproj file:
<ItemGroup>
<ProjectReference Include="..\..\src\QwkSync\QwkSync.csproj" />
</ItemGroup>
Create a factory class that implements ITransportFactory:
using System;
using QwkSync;
namespace QwkSync.YourTransport;
/// <summary>
/// Factory for creating <see cref="YourTransport"/> instances.
/// </summary>
public sealed class YourTransportFactory : ITransportFactory
{
/// <summary>
/// Gets the unique identifier for this transport type.
/// </summary>
/// <value>The transport identifier: "your-transport", e.g., "http".</value>
public string TransportId => "your-transport";
/// <summary>
/// Creates a new <see cref="YourTransport"/> instance configured with the specified profile and policy.
/// </summary>
/// <param name="profile">The synchronisation profile containing connection details.</param>
/// <param name="policy">The transfer policy controlling retries and timeouts.</param>
/// <returns>A new <see cref="YourTransport"/> instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="profile"/> or <paramref name="policy"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when the profile's transport ID does not match.</exception>
public ITransport Create(QwkSyncProfile profile, TransferPolicy policy)
{
if (profile == null)
throw new ArgumentNullException(nameof(profile));
if (policy == null)
throw new ArgumentNullException(nameof(policy));
if (!string.Equals(profile.TransportId, TransportId, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Profile transport ID '{profile.TransportId}' does not match expected '{TransportId}'.", nameof(profile));
return new YourTransport(profile, policy);
}
}
Important points:
TransportId must be unique and should use kebab-case (e.g., “your-transport”)profile and policy are not nullTransportId matches your factory’s IDCreate a transport class that implements ITransport:
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using QwkSync;
namespace QwkSync.YourTransport;
/// <summary>
/// A transport implementation for [describe your protocol/endpoint].
/// </summary>
public sealed class YourTransport : ITransport
{
private readonly QwkSyncProfile _profile;
private readonly TransferPolicy _policy;
/// <summary>
/// Initialises a new instance of the <see cref="YourTransport"/> class.
/// </summary>
/// <param name="profile">The synchronisation profile containing connection details.</param>
/// <param name="policy">The transfer policy to use.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="profile"/> or <paramref name="policy"/> is <c>null</c>.</exception>
public YourTransport(QwkSyncProfile profile, TransferPolicy policy)
{
if (profile == null)
throw new ArgumentNullException(nameof(profile));
if (policy == null)
throw new ArgumentNullException(nameof(policy));
_profile = profile;
_policy = policy;
// Initialise your transport-specific resources here
}
/// <inheritdoc/>
public async Task<IReadOnlyList<RemoteItem>> ListAsync(
string? remotePath,
string searchPattern,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(searchPattern))
throw new ArgumentException("Search pattern cannot be null or empty.", nameof(searchPattern));
// TODO: Implement file listing
// - Connect to remote endpoint using _profile.Endpoint
// - List files at remotePath (or root if null)
// - Filter by searchPattern
// - Return RemoteItem instances with Name, Path, Size, LastModified
return Array.Empty<RemoteItem>();
}
/// <inheritdoc/>
public async Task DownloadAsync(
RemoteItem item,
string localTempPath,
IProgress<QwkSyncProgress>? progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (item == null)
throw new ArgumentNullException(nameof(item));
if (string.IsNullOrWhiteSpace(localTempPath))
throw new ArgumentException("Local temporary file path cannot be null or empty.", nameof(localTempPath));
// TODO: Implement file download
// - Download file from remote endpoint
// - Write to localTempPath (this is a temp file for atomic download)
// - Report progress via progress?.Report(new QwkSyncProgress { ... })
// - Honour cancellation via ct
// - Throw appropriate exceptions on failure
await Task.CompletedTask;
}
/// <inheritdoc/>
public async Task UploadAsync(
string localFilePath,
string? remotePath,
string remoteFileName,
IProgress<QwkSyncProgress>? progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(localFilePath))
throw new ArgumentException("Local file path cannot be null or empty.", nameof(localFilePath));
if (string.IsNullOrWhiteSpace(remoteFileName))
throw new ArgumentException("Remote file name cannot be null or empty.", nameof(remoteFileName));
if (!File.Exists(localFilePath))
throw new FileNotFoundException($"Local file not found: {localFilePath}", localFilePath);
// TODO: Implement file upload
// - Read file from localFilePath
// - Upload to remotePath (or default if null) with remoteFileName
// - Report progress via progress?.Report(new QwkSyncProgress { ... })
// - Honour cancellation via ct
// - Throw appropriate exceptions on failure
await Task.CompletedTask;
}
/// <inheritdoc/>
public async Task DeleteAsync(RemoteItem item, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (item == null)
throw new ArgumentNullException(nameof(item));
// TODO: Implement file deletion
// - Delete file from remote endpoint
// - Honour cancellation via ct
// - Throw appropriate exceptions on failure
await Task.CompletedTask;
}
/// <inheritdoc/>
public async Task MoveAsync(RemoteItem item, string destinationPath, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (item == null)
throw new ArgumentNullException(nameof(item));
if (string.IsNullOrWhiteSpace(destinationPath))
throw new ArgumentException("Destination path cannot be null or empty.", nameof(destinationPath));
// TODO: Implement file move
// - Move file from current location to destinationPath
// - Honour cancellation via ct
// - Throw appropriate exceptions on failure
await Task.CompletedTask;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
// TODO: Dispose of any resources (connections, streams, etc.)
await Task.CompletedTask;
}
}
Important points:
CancellationToken at the start of each methodIAsyncDisposable to clean up resourcesDeleteAsync and MoveAsync are only called when explicitly configured via RemoteLifecycleYour transport can access configuration through QwkSyncProfile:
// Access endpoint
Uri endpoint = _profile.Endpoint;
// Access credentials
CredentialSource credentials = _profile.Credentials;
// Access custom settings
string? customSetting = _profile.Settings.TryGetValue("CustomSetting", out string? value) ? value : null;
Use _profile.Settings for transport-specific configuration (e.g., API keys, connection timeouts).
Your transport should respect the TransferPolicy where applicable:
// The policy is provided to your transport
// QWKSync.NET handles retries, but you should respect timeouts where possible
// Timeout is applied per operation by QWKSync.NET, but you can use it for guidance
TimeSpan timeout = _policy.Timeout;
Note: QWKSync.NET orchestrates retries. Your transport should focus on implementing the operations correctly. QWKSync.NET will retry failed operations according to MaxRetries.
Report progress during downloads and uploads:
public async Task DownloadAsync(..., IProgress<QwkSyncProgress>? progress, ...)
{
long bytesDownloaded = 0;
long totalBytes = item.Size ?? 0; // Use Size from RemoteItem if available
// During download loop:
bytesDownloaded += chunkSize;
progress?.Report(new QwkSyncProgress
{
BytesTransferred = bytesDownloaded,
TotalBytes = totalBytes > 0 ? totalBytes : null
});
}
Progress reporting is informational only and does not affect sync behaviour.
Throw appropriate exceptions for different error conditions:
ArgumentException — for invalid parametersFileNotFoundException — for missing filesUnauthorizedAccessException — for authentication/authorization failuresIOException — for I/O errorsOperationCanceledException — when cancellation is requested (already handled by QWKSync.NET library)QWKSync.NET will catch these and translate them into QwkSyncResult issues where appropriate.
If you need to register your transport with a custom registry:
using System.Collections.Generic;
using QwkSync;
namespace QwkSync.YourTransport;
/// <summary>
/// Helper class for creating a client with your transport registered.
/// </summary>
public static class YourTransportClient
{
/// <summary>
/// Creates a <see cref="QwkSyncClient"/> with your transport factory registered.
/// </summary>
/// <returns>A client instance with your transport available.</returns>
public static QwkSyncClient CreateClient()
{
// This requires access to internal ITransportFactoryRegistry
// You may need to create a custom registry implementation
// For now, users can register manually if needed
return new QwkSyncClient();
}
}
Note: The default QwkSyncClient only includes the built-in LocalFolderTransport. Users of your extension will need to create a custom client with a registry that includes your factory, or you can provide a factory method that does this.
Create unit tests for your transport:
[Fact]
public void Create_WithValidProfile_ReturnsTransport()
{
QwkSyncProfile profile = new QwkSyncProfile
{
Endpoint = new Uri("your://endpoint"),
TransportId = "your-transport"
};
TransferPolicy policy = TransferPolicy.Default;
YourTransportFactory factory = new YourTransportFactory();
ITransport transport = factory.Create(profile, policy);
Assert.NotNull(transport);
}
[Fact]
public void ListAsync_WithValidPath_ReturnsFiles()
{
// Test your transport implementation
}
Document your transport extension:
Here is a minimal complete skeleton:
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using QwkSync;
namespace QwkSync.YourTransport;
public sealed class YourTransportFactory : ITransportFactory
{
public string TransportId => "your-transport";
public ITransport Create(QwkSyncProfile profile, TransferPolicy policy)
{
if (profile == null)
throw new ArgumentNullException(nameof(profile));
if (policy == null)
throw new ArgumentNullException(nameof(policy));
if (!string.Equals(profile.TransportId, TransportId, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Transport ID mismatch.", nameof(profile));
return new YourTransport(profile, policy);
}
}
public sealed class YourTransport : ITransport
{
private readonly QwkSyncProfile _profile;
private readonly TransferPolicy _policy;
public YourTransport(QwkSyncProfile profile, TransferPolicy policy)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
}
public Task<IReadOnlyList<RemoteItem>> ListAsync(string? remotePath, string searchPattern, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Implement listing
return Task.FromResult<IReadOnlyList<RemoteItem>>(Array.Empty<RemoteItem>());
}
public Task DownloadAsync(RemoteItem item, string localTempPath, IProgress<QwkSyncProgress>? progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Implement download
return Task.CompletedTask;
}
public Task UploadAsync(string localFilePath, string? remotePath, string remoteFileName, IProgress<QwkSyncProgress>? progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Implement upload
return Task.CompletedTask;
}
public Task DeleteAsync(RemoteItem item, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Implement delete
return Task.CompletedTask;
}
public Task MoveAsync(RemoteItem item, string destinationPath, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Implement move
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
// Cleanup
return ValueTask.CompletedTask;
}
}
var, British English spellingsCancellationToken and respect it promptlyDisposeAsync properlySee src/QwkSync.Http for a reference implementation (note: it is currently a stub that throws NotSupportedException until an HTTP protocol is defined).