QwkSync

Creating Transport Extensions

This guide walks you through creating a custom transport extension for QWKSync.NET.

Overview

Transport extensions allow you to add support for new protocols (HTTP, FTP, SFTP, etc.) or custom endpoints. A transport extension consists of:

  1. An ITransport implementation — performs the actual file operations
  2. An ITransportFactory implementation — creates transport instances
  3. Registration with a transport factory registry — makes the transport available

Step 1: Create a New Project

Create 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>

Step 2: Implement ITransportFactory

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:

Step 3: Implement ITransport

Create 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:

Step 4: Handle Profile Configuration

Your 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).

Step 5: Respect TransferPolicy

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.

Step 6: Implement Progress Reporting

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.

Step 7: Handle Errors Appropriately

Throw appropriate exceptions for different error conditions:

QWKSync.NET will catch these and translate them into QwkSyncResult issues where appropriate.

Step 8: Create a Custom Client (Optional)

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.

Step 9: Testing

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
}

Step 10: Documentation

Document your transport extension:

Example: Minimal Transport Skeleton

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

Best Practices

  1. Follow coding standards: Use 2-space indentation, no var, British English spellings
  2. Validate everything: Check parameters, endpoint format, connectivity
  3. Handle cancellation: Always check CancellationToken and respect it promptly
  4. Clean up resources: Implement DisposeAsync properly
  5. Report progress: Provide progress updates where possible
  6. Throw appropriate exceptions: Use standard .NET exception types
  7. Document behaviour: Clearly document protocol-specific behaviour and requirements
  8. Test thoroughly: Create comprehensive tests for all operations and error cases

Reference Implementation

See src/QwkSync.Http for a reference implementation (note: it is currently a stub that throws NotSupportedException until an HTTP protocol is defined).