PresignedUrlClient.Serialization.SystemTextJson 2.4.1

dotnet add package PresignedUrlClient.Serialization.SystemTextJson --version 2.4.1
                    
NuGet\Install-Package PresignedUrlClient.Serialization.SystemTextJson -Version 2.4.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PresignedUrlClient.Serialization.SystemTextJson" Version="2.4.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PresignedUrlClient.Serialization.SystemTextJson" Version="2.4.1" />
                    
Directory.Packages.props
<PackageReference Include="PresignedUrlClient.Serialization.SystemTextJson" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PresignedUrlClient.Serialization.SystemTextJson --version 2.4.1
                    
#r "nuget: PresignedUrlClient.Serialization.SystemTextJson, 2.4.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PresignedUrlClient.Serialization.SystemTextJson@2.4.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PresignedUrlClient.Serialization.SystemTextJson&version=2.4.1
                    
Install as a Cake Addin
#tool nuget:?package=PresignedUrlClient.Serialization.SystemTextJson&version=2.4.1
                    
Install as a Cake Tool

๐Ÿš€ PresignedUrlClient

.NET Standard 2.1 License: Proprietary Version Tests Coverage Build

A resilient, production-ready .NET Standard 2.1 client library for S3 Presigned URL Services with built-in async/await, circuit breaker, retry logic, automatic failover, first-class storage operations, automatic S3 integration, and intelligent ETag normalization for multipart uploads.


๐Ÿ“‘ Table of Contents


โœ… Status

v2.4.1 - Production Ready ๐ŸŽ‰
๐Ÿ› FIXED: Critical multipart upload completion bug - now sends required XML body to S3!
โœจ Intelligent ETag normalization - handles any quote format automatically!
โœจ Enhanced validation for multipart upload completion!
โœจ Resilient to S3 API changes or proxy modifications!
๐Ÿ”’ Backward Compatible: All v2.3.0 code continues to work - zero breaking changes!

โœจ Features

Core Capabilities

  • ๐Ÿ”— Presigned URL Generation - GET and PUT operations for S3 objects
  • โšก Async/Await Support - Full async methods with CancellationToken (v2.0)
  • ๐Ÿ“‚ Storage Operations - First-class upload/download with progress tracking (NEW in v2.1)
  • ๐Ÿ“ฆ Multipart Upload - Complete workflow for large files (5MB - 5TB)
  • ๐Ÿ” API Key Authentication - Secure X-API-Key header authentication
  • โš™๏ธ Service Configuration Discovery - Automatic bucket and region detection
  • ๐ŸŽฏ Type-Safe Models - Strongly-typed requests/responses with built-in validation

Resilience & Reliability โญ

  • ๐Ÿ”„ Automatic Retry Logic - Configurable retry attempts with exponential backoff
  • ๐Ÿ›ก๏ธ Circuit Breaker Pattern - Prevents cascading failures when service is down
  • โฑ๏ธ Timeout Management - Configurable request timeouts with cancellation support
  • ๐Ÿšจ Comprehensive Error Handling - Specific exception types (400, 401, 403, 500)
  • ๐Ÿ“Š Zero External Dependencies - Only .NET Standard 2.1 + ResilientHttpClient.Core

Storage Operations (v2.1) + Enhanced Progress (v2.2) + S3 Integration (v2.3) + ETag Resilience (v2.4) โญ

  • ๐Ÿ“ค Upload Files - Stream-based upload with continuous progress tracking (fixed in v2.2!)
  • ๐Ÿ“ฅ Download Files - Stream-based download with progress tracking
  • ๐Ÿ”„ Automatic S3 Integration - Handles both backend-initiated and client-initiated multipart uploads (v2.3)
  • ๐Ÿท๏ธ Intelligent ETag Normalization - Handles any ETag quote format automatically (v2.4)
  • ๐Ÿ”ข Multipart Workflow - Complete initiate โ†’ upload parts โ†’ complete/abort with per-part progress
  • โœ… ETag Validation - Data integrity verification with automatic format handling
  • ๐Ÿ“Š Hybrid Progress - Rich (IProgress<UploadProgress>) + Simple (IProgress<double>) options (NEW v2.2)
  • โŒ Cancellation Support - Graceful operation cancellation

Developer Experience

  • ๐Ÿ’‰ Dependency Injection Ready - First-class Microsoft.Extensions.DependencyInjection support
  • โœ… 95%+ Code Coverage - 205 tests (100% pass rate) covering all scenarios including new progress tracking
  • ๐Ÿ“– XML Documentation - IntelliSense-friendly API documentation
  • ๐ŸŽจ Clean Architecture - SOLID principles with clear separation of concerns
  • ๐Ÿ”ง Configuration Binding - Seamless appsettings.json integration
  • ๐Ÿš€ Enhanced Sample Project - Real upload/download with visual success/failure tracking and comprehensive test results

๐Ÿ—๏ธ Architecture

graph TB
    subgraph "๐ŸŽฏ Client Application"
        A[Your Service/Controller]
    end
    
    subgraph "๐Ÿ“ฆ PresignedUrlClient Library"
        B[IPresignedUrlService<br/>Interface]
        C[PresignedUrlService<br/>Implementation]
        D[IResilientHttpClient<br/>Resilient HTTP Layer]
        E[RequestBuilder<br/>JSON Serialization]
        F[ResponseParser<br/>Error Mapping]
    end
    
    subgraph "๐Ÿ›ก๏ธ Resilience Layer"
        G[Retry Logic<br/>3 attempts]
        H[Circuit Breaker<br/>Failure Detection]
        I[Timeout Handler<br/>30s default]
    end
    
    subgraph "๐ŸŒ External Service"
        J[S3 Presigned URL<br/>Service API]
    end
    
    A -->|Dependency Injection| B
    B -.Implements.- C
    C -->|Uses| D
    C -->|Builds Requests| E
    C -->|Parses Responses| F
    D -->|Applies| G
    D -->|Monitors| H
    D -->|Enforces| I
    D -->|HTTPS POST/GET| J
    
    style B fill:#e1f5ff,stroke:#0066cc,stroke-width:2px
    style D fill:#fff4e1,stroke:#ff9900,stroke-width:2px
    style J fill:#ffe1e1,stroke:#cc0000,stroke-width:2px
    style G fill:#e8f5e9,stroke:#4caf50
    style H fill:#e8f5e9,stroke:#4caf50
    style I fill:#e8f5e9,stroke:#4caf50

๐Ÿ“‚ Project Structure

PresignedUrlClient/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ PresignedUrlClient.Abstractions/    # ๐Ÿ“‹ Interfaces, Models, Exceptions
โ”‚   โ”‚   โ”œโ”€โ”€ IPresignedUrlService.cs
โ”‚   โ”‚   โ”œโ”€โ”€ IPresignedUrlConfig.cs
โ”‚   โ”‚   โ”œโ”€โ”€ Models/                          # Request & Response DTOs
โ”‚   โ”‚   โ”œโ”€โ”€ Enums/                           # S3Operation enum
โ”‚   โ”‚   โ””โ”€โ”€ Exceptions/                      # Custom exception hierarchy
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ PresignedUrlClient.Core/             # โš™๏ธ Implementation Logic
โ”‚   โ”‚   โ”œโ”€โ”€ PresignedUrlService.cs           # Main service implementation
โ”‚   โ”‚   โ”œโ”€โ”€ Configuration/                   # Options & validation
โ”‚   โ”‚   โ””โ”€โ”€ Internal/                        # RequestBuilder, ResponseParser
โ”‚   โ”‚
โ”‚   โ””โ”€โ”€ PresignedUrlClient.DependencyInjection/  # ๐Ÿ’‰ DI Extensions
โ”‚       โ””โ”€โ”€ ServiceCollectionExtensions.cs
โ”‚
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ PresignedUrlClient.Core.Tests/       # ๐Ÿงช ~60 Unit & Integration Tests
โ”‚   โ”œโ”€โ”€ PresignedUrlClient.DependencyInjection.Tests/  # ~15 DI Tests
โ”‚   โ”œโ”€โ”€ PresignedUrlClient.Serialization.SystemTextJson.Tests/  # ~7 Serialization Tests
โ”‚   โ””โ”€โ”€ PresignedUrlClient.Serialization.NewtonsoftJson.Tests/  # ~7 Serialization Tests
โ”‚
โ””โ”€โ”€ docs/
    โ”œโ”€โ”€ PLANNING.md                          # Architecture decisions
    โ””โ”€โ”€ TASKS.md                             # Development tracker

๐Ÿš€ Quick Start

๐Ÿ“‹ Requirements

  • .NET Standard 2.1+ compatible framework:
    • โœ… .NET Core 3.0, 3.1
    • โœ… .NET 5, 6, 7, 8, 9+
    • โœ… .NET Framework 4.8+ (with .NET Standard 2.1 support)
  • .NET SDK 6.0+ for building and testing

๐Ÿ“ฆ Installation

Core Package (URL Generation Only)

# Minimum installation for presigned URL generation
dotnet add reference path/to/PresignedUrlClient.DependencyInjection

Storage Package (Upload/Download) - NEW v2.1 โญ

# Full installation including storage operations
dotnet add reference path/to/PresignedUrlClient.DependencyInjection
dotnet add reference path/to/PresignedUrlClient.Storage.DependencyInjection

Step 2: Register Services

Option A: In Program.cs (.NET 6+)
using PresignedUrlClient.DependencyInjection;
using PresignedUrlClient.Storage.DependencyInjection;  // NEW v2.1

var builder = WebApplication.CreateBuilder(args);

// Register PresignedUrlClient services
builder.Services.AddPresignedUrlClient(options =>
{
    options.BaseUrl = "https://presigned-url-service.example.com";
    options.ApiKey = builder.Configuration["PresignedUrlService:ApiKey"]!;
    options.DefaultExpiresIn = 3600; // 1 hour default
    
    // โญ Resilience settings (optional - these are defaults)
    options.RetryCount = 3;                       // 3 retry attempts
    options.RetryDelayMilliseconds = 1000;        // 1 second between retries
    options.CircuitBreakerThreshold = 5;          // Open circuit after 5 failures
    options.CircuitBreakerDurationSeconds = 60;   // Keep circuit open for 60s
});

// โญ NEW v2.1: Register Storage services for upload/download
builder.Services.AddPresignedUrlStorage(options =>
{
    options.DefaultTimeout = TimeSpan.FromMinutes(30);
    options.BufferSize = 81920; // 80KB
    options.MultipartThreshold = 5 * 1024 * 1024; // 5MB
});

var app = builder.Build();
Option B: In Startup.cs (.NET Core 3.1, .NET 5)
using PresignedUrlClient.DependencyInjection;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddPresignedUrlClient(options =>
        {
            options.BaseUrl = "https://presigned-url-service.example.com";
            options.ApiKey = Configuration["PresignedUrlService:ApiKey"]!;
        });
    }
}

Step 3: Inject and Use

using PresignedUrlClient.Abstractions;
using PresignedUrlClient.Abstractions.Enums;
using PresignedUrlClient.Abstractions.Models;

public class FileService
{
    private readonly IPresignedUrlService _presignedUrlService;

    public FileService(IPresignedUrlService presignedUrlService)
    {
        _presignedUrlService = presignedUrlService;
    }

    public PresignedUrlResponse GetDownloadUrl(string bucket, string key)
    {
        var request = new PresignedUrlRequest(
            bucket: bucket,
            key: key,
            operation: S3Operation.GetObject,
            expiresIn: 3600
        );

        return _presignedUrlService.GeneratePresignedUrl(request);
    }

    public PresignedUrlResponse GetUploadUrl(string bucket, string key, string contentType)
    {
        var request = new PresignedUrlRequest(
            bucket: bucket,
            key: key,
            operation: S3Operation.PutObject,
            contentType: contentType,
            expiresIn: 3600
        );

        return _presignedUrlService.GeneratePresignedUrl(request);
    }
}

โš™๏ธ Configuration

Option 1: Direct Configuration (Code-Based)

services.AddPresignedUrlClient(options =>
{
    // Required settings
    options.BaseUrl = "https://presigned-url-service.example.com";
    options.ApiKey = "your-api-key";
    
    // Optional settings with defaults
    options.DefaultExpiresIn = 3600;           // Default: 3600 (1 hour)
    options.Timeout = TimeSpan.FromSeconds(30); // Default: 30 seconds
    
    // โญ Resilience settings (NEW in v1.0.0)
    options.RetryCount = 3;                       // Default: 3 attempts
    options.RetryDelayMilliseconds = 1000;        // Default: 1000ms (1 second)
    options.CircuitBreakerThreshold = 5;          // Default: 5 consecutive failures
    options.CircuitBreakerDurationSeconds = 60;   // Default: 60 seconds
});

Option 2: Configuration Binding (appsettings.json)

appsettings.json:

{
  "PresignedUrlClient": {
    "BaseUrl": "https://presigned-url-service.example.com",
    "ApiKey": "your-api-key",
    "DefaultExpiresIn": 3600,
    "Timeout": "00:00:30",
    
    // Resilience configuration
    "RetryCount": 5,
    "RetryDelayMilliseconds": 2000,
    "CircuitBreakerThreshold": 10,
    "CircuitBreakerDurationSeconds": 120
  }
}

Program.cs:

builder.Services.AddPresignedUrlClient(
    builder.Configuration.GetSection("PresignedUrlClient")
);

โš™๏ธ Configuration Options Reference

Option Type Default Description
BaseUrl string Required Base URL of the presigned URL service
ApiKey string Required API key for authentication
DefaultExpiresIn int 3600 Default URL expiration (seconds)
Timeout TimeSpan 30s HTTP request timeout
RetryCount int 3 Number of retry attempts for transient failures
RetryDelayMilliseconds int 1000 Delay between retry attempts (milliseconds)
CircuitBreakerThreshold int 5 Consecutive failures before circuit opens
CircuitBreakerDurationSeconds int 60 How long circuit stays open (seconds)

๐Ÿ›ก๏ธ Resilience Patterns

The library includes built-in resilience patterns powered by ResilientHttpClient.Core to handle transient failures and prevent cascading errors.

๐Ÿ”„ Automatic Retry Logic

sequenceDiagram
    participant App as Your Application
    participant Client as PresignedUrlClient
    participant Retry as Retry Handler
    participant Service as S3 URL Service
    
    App->>Client: GeneratePresignedUrl()
    Client->>Retry: Execute Request
    
    Retry->>Service: Attempt 1
    Service-->>Retry: โŒ Timeout
    Note over Retry: Wait 1 second
    
    Retry->>Service: Attempt 2
    Service-->>Retry: โŒ 500 Error
    Note over Retry: Wait 1 second
    
    Retry->>Service: Attempt 3
    Service-->>Retry: โœ… 200 OK
    
    Retry-->>Client: Success
    Client-->>App: Return Response

Retried Errors:

  • โฑ๏ธ Timeouts (TaskCanceledException)
  • ๐ŸŒ Network errors (HttpRequestException)
  • ๐Ÿ”ฅ Server errors (500, 503)

Not Retried:

  • โŒ Client errors (400, 401, 403, 404)
  • โœ… Success responses (200, 201)

๐Ÿ›ก๏ธ Circuit Breaker Pattern

stateDiagram-v2
    [*] --> Closed: Initial State
    
    Closed --> Open: 5 consecutive failures
    Closed --> Closed: Successful request
    Closed --> Closed: Failed request (count < 5)
    
    Open --> HalfOpen: After 60 seconds
    Open --> Open: Any request (fast fail)
    
    HalfOpen --> Closed: Test request succeeds
    HalfOpen --> Open: Test request fails
    
    note right of Closed
        Normal operation
        All requests allowed
    end note
    
    note right of Open
        Service assumed down
        Fail fast (no network calls)
    end note
    
    note right of HalfOpen
        Testing recovery
        One request allowed
    end note

Benefits:

  • ๐Ÿš€ Fast Fail - Don't wait for timeouts when service is down
  • ๐Ÿ’ฐ Resource Protection - Save network/CPU resources
  • ๐Ÿ”„ Auto-Recovery - Automatically detect when service recovers

โš™๏ธ Customizing Resilience Behavior

// For a critical operation - more aggressive retry
services.AddPresignedUrlClient(options =>
{
    options.RetryCount = 5;                      // Try 5 times
    options.RetryDelayMilliseconds = 500;        // Retry quickly (500ms)
    options.CircuitBreakerThreshold = 10;        // More tolerant of failures
});

// For a non-critical operation - fail fast
services.AddPresignedUrlClient(options =>
{
    options.RetryCount = 1;                      // Only 1 retry
    options.RetryDelayMilliseconds = 100;        // Short delay
    options.CircuitBreakerThreshold = 3;         // Trip quickly
});

๐Ÿ“– Usage Examples

๐Ÿ“ฅ Get Presigned URL for Download (GET)

using PresignedUrlClient.Abstractions;
using PresignedUrlClient.Abstractions.Enums;
using PresignedUrlClient.Abstractions.Models;

// Inject IPresignedUrlService into your service/controller
public class DocumentService
{
    private readonly IPresignedUrlService _urlService;
    
    public DocumentService(IPresignedUrlService urlService)
    {
        _urlService = urlService;
    }
    
    public PresignedUrlResponse GetDownloadLink(string bucket, string key)
    {
        var request = new PresignedUrlRequest(
            bucket: bucket,
            key: key,
            operation: S3Operation.GetObject,
            expiresIn: 3600  // URL valid for 1 hour
        );

        var response = _urlService.GeneratePresignedUrl(request);

        // Response contains:
        // - response.Url: The presigned URL
        // - response.ExpiresIn: Seconds until expiration (3600)
        // - response.ExpiresAt: Exact DateTime when URL expires
        
        return response;
    }
}

Response Example:

{
  "url": "https://my-bucket.s3.amazonaws.com/documents/report.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
  "expiresIn": 3600,
  "expiresAt": "2025-10-09T14:30:00Z"
}

๐Ÿ“ค Get Presigned URL for Upload (PUT)

public async Task<string> UploadFile(Stream fileStream, string fileName)
{
    // 1. Get presigned URL for upload
    var request = new PresignedUrlRequest(
        bucket: "uploads-bucket",
        key: $"users/{userId}/{fileName}",
        operation: S3Operation.PutObject,
        contentType: "image/jpeg",
        expiresIn: 1800  // URL valid for 30 minutes
    );

    var response = _urlService.GeneratePresignedUrl(request);

    // 2. Upload file directly to S3 using the presigned URL
    using var httpClient = new HttpClient();
    using var content = new StreamContent(fileStream);
    content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");

    var uploadResponse = await httpClient.PutAsync(response.Url, content);
    uploadResponse.EnsureSuccessStatusCode();
    
    return response.Url.Split('?')[0]; // Return permanent S3 URL (without query params)
}

โš™๏ธ Get Service Configuration

public void DisplayServiceInfo()
{
    var config = _urlService.GetConfiguration();

    Console.WriteLine($"โœ… Service: {config.Service}");
    Console.WriteLine($"๐Ÿ“ฆ Version: {config.Version}");
    Console.WriteLine($"\n๐Ÿ“ Available Buckets:");

    foreach (var (bucketName, bucketConfig) in config.Buckets)
    {
        Console.WriteLine($"\n  ๐Ÿชฃ {bucketName}");
        Console.WriteLine($"     Region: {bucketConfig.Region}");
        Console.WriteLine($"     Max Expiry: {bucketConfig.MaxExpiry}s");
        Console.WriteLine($"     Operations: {string.Join(", ", bucketConfig.AllowedOperations)}");
    }
}

Output Example:

โœ… Service: S3 Presigned URL Service
๐Ÿ“ฆ Version: 1.0.0

๐Ÿ“ Available Buckets:

  ๐Ÿชฃ documents
     Region: us-east-1
     Max Expiry: 86400s
     Operations: GetObject, PutObject

  ๐Ÿชฃ media
     Region: eu-west-1
     Max Expiry: 3600s
     Operations: GetObject

โšก Async/Await Support (NEW in v2.0.0)

All service methods now have async counterparts with CancellationToken support for better scalability and responsiveness.

๐Ÿ“ฅ Async Download URL Generation
public async Task<PresignedUrlResponse> GetDownloadLinkAsync(string bucket, string key, CancellationToken cancellationToken = default)
{
    var request = new PresignedUrlRequest(
        bucket: bucket,
        key: key,
        operation: S3Operation.GetObject,
        expiresIn: 3600
    );

    // Use async method for non-blocking I/O
    var response = await _urlService.GeneratePresignedUrlAsync(request, cancellationToken);
    return response;
}
๐Ÿ“ค Async Upload with Cancellation
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, CancellationToken cancellationToken)
{
    // 1. Get presigned URL asynchronously
    var request = new PresignedUrlRequest(
        bucket: "uploads-bucket",
        key: $"users/{userId}/{fileName}",
        operation: S3Operation.PutObject,
        contentType: "image/jpeg",
        expiresIn: 1800
    );

    var response = await _urlService.GeneratePresignedUrlAsync(request, cancellationToken);

    // 2. Upload file to S3
    using var httpClient = new HttpClient();
    using var content = new StreamContent(fileStream);
    content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");

    var uploadResponse = await httpClient.PutAsync(response.Url, content, cancellationToken);
    uploadResponse.EnsureSuccessStatusCode();
    
    return response.Url.Split('?')[0];
}
โš™๏ธ Async Configuration Retrieval
public async Task<ConfigurationResponse> GetServiceInfoAsync(CancellationToken cancellationToken = default)
{
    // Non-blocking configuration fetch
    var config = await _urlService.GetConfigurationAsync(cancellationToken);
    return config;
}
๐Ÿ”„ Concurrent Async Operations
public async Task<IEnumerable<PresignedUrlResponse>> GenerateMultipleUrlsAsync(
    IEnumerable<string> keys,
    CancellationToken cancellationToken = default)
{
    var tasks = keys.Select(key => 
        _urlService.GeneratePresignedUrlAsync(
            new PresignedUrlRequest("my-bucket", key, S3Operation.GetObject),
            cancellationToken
        )
    );

    // Execute all requests concurrently
    var results = await Task.WhenAll(tasks);
    return results;
}
โฑ๏ธ Async with Timeout
public async Task<PresignedUrlResponse> GetUrlWithTimeoutAsync(string key)
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    
    try
    {
        var request = new PresignedUrlRequest("my-bucket", key, S3Operation.GetObject);
        return await _urlService.GeneratePresignedUrlAsync(request, cts.Token);
    }
    catch (OperationCanceledException)
    {
        // Handle timeout/cancellation
        throw new TimeoutException("Request timed out after 5 seconds");
    }
}

๐Ÿ“‚ Storage Operations (v2.1) + Enhanced Progress (v2.2) โญ

Complete file upload and download operations with universal progress tracking, built on top of presigned URLs.

What's New in v2.2.0:

  • โœ… Upload progress now reports continuously (not just 0% and 100%)
  • โœ… Simple PercentProgress option for easy progress bars
  • โœ… Multipart uploads support per-part progress tracking
  • โœ… Use rich OR simple OR both progress reporters simultaneously
  • โœ… 100% backward compatible - no breaking changes

๐Ÿ“ค Upload File with Progress

using PresignedUrlClient.Storage;
using PresignedUrlClient.Storage.Models;

public class StorageService
{
    private readonly IStorageService _storage;
    
    public StorageService(IStorageService storage)
    {
        _storage = storage;
    }
    
    public async Task<UploadResult> UploadFileAsync(Stream fileStream, string bucket, string key)
    {
        var options = new UploadOptions
        {
            ContentType = "application/pdf",
            Progress = new Progress<UploadProgress>(p =>
            {
                Console.WriteLine($"Uploaded: {p.BytesUploaded}/{p.TotalBytes} bytes ({p.PercentComplete:F1}%)");
            })
        };
        
        var result = await _storage.UploadAsync(bucket, key, fileStream, options);
        
        Console.WriteLine($"โœ… Upload complete! ETag: {result.ETag}");
        Console.WriteLine($"   Duration: {result.Duration.TotalSeconds:F2}s");
        Console.WriteLine($"   Bytes: {result.BytesUploaded}");
        
        return result;
    }
}

๐Ÿ“ฅ Download File with Progress

public async Task<DownloadResult> DownloadFileAsync(string bucket, string key, string localPath)
{
    using var outputStream = File.Create(localPath);
    
    var options = new DownloadOptions
    {
        Progress = new Progress<DownloadProgress>(p =>
        {
            Console.WriteLine($"Downloaded: {p.BytesDownloaded}/{p.TotalBytes} bytes ({p.PercentComplete:F1}%)");
        })
    };
    
    var result = await _storage.DownloadAsync(bucket, key, outputStream, options);
    
    Console.WriteLine($"โœ… Download complete!");
    Console.WriteLine($"   ETag: {result.ETag}");
    Console.WriteLine($"   ContentType: {result.ContentType}");
    Console.WriteLine($"   Size: {result.BytesDownloaded} bytes");
    
    return result;
}

๐Ÿ“Š Progress Reporting Options

The library provides two ways to track upload/download progress:

Option 1: Rich Progress (Detailed Information)

Get detailed progress information including bytes transferred, percentages, and multipart details:

var options = new UploadOptions
{
    Progress = new Progress<UploadProgress>(p => 
    {
        Console.WriteLine($"Uploaded: {p.BytesUploaded:N0} / {p.TotalBytes:N0} bytes");
        Console.WriteLine($"Progress: {p.PercentComplete:F1}%");
        
        if (p.IsMultipart)
        {
            Console.WriteLine($"Part: {p.CurrentPart} / {p.TotalParts}");
        }
    })
};
Option 2: Simple Percentage (Easy to Use)

For simple scenarios where you only need the completion percentage:

var options = new UploadOptions
{
    PercentProgress = new Progress<double>(percent => 
        Console.WriteLine($"Upload: {percent:F1}%"))
};
Use Both (Best of Both Worlds)

You can use both progress reporters simultaneously:

var options = new UploadOptions
{
    Progress = richProgressHandler,       // For detailed UI/logging
    PercentProgress = simpleProgressBar    // For progress bar
};

Available for:

  • โœ… Upload operations (UploadOptions.Progress / UploadOptions.PercentProgress)
  • โœ… Download operations (DownloadOptions.Progress / DownloadOptions.PercentProgress)
  • โœ… Multipart part uploads (IMultipartUploadService.UploadPartAsync with IProgress<long>)

๐Ÿ”ข Multipart Upload (Low-Level Control)

For fine-grained control over multipart uploads:

using PresignedUrlClient.Storage;

public class MultipartService
{
    private readonly IMultipartUploadService _multipart;
    
    public MultipartService(IMultipartUploadService multipart)
    {
        _multipart = multipart;
    }
    
    public async Task UploadLargeFileAsync(string filePath, string bucket, string key)
    {
        const int chunkSize = 5 * 1024 * 1024; // 5MB chunks
        
        // 1. Initiate
        var uploadId = await _multipart.InitiateAsync(bucket, key, "application/octet-stream");
        
        try
        {
            // 2. Upload parts
            using var fileStream = File.OpenRead(filePath);
            var parts = new List<PartInfo>();
            int partNumber = 1;
            
            while (fileStream.Position < fileStream.Length)
            {
                var chunk = new byte[Math.Min(chunkSize, fileStream.Length - fileStream.Position)];
                await fileStream.ReadAsync(chunk, 0, chunk.Length);
                
                using var chunkStream = new MemoryStream(chunk);
                var partResult = await _multipart.UploadPartAsync(
                    bucket, key, uploadId, partNumber, chunkStream);
                
                parts.Add(new PartInfo(partNumber, partResult.ETag));
                partNumber++;
                
                Console.WriteLine($"Uploaded part {partNumber-1}, ETag: {partResult.ETag}");
            }
            
            // 3. Complete
            await _multipart.CompleteAsync(bucket, key, uploadId, parts);
            Console.WriteLine("โœ… Multipart upload complete!");
        }
        catch
        {
            // Abort on error
            await _multipart.AbortAsync(bucket, key, uploadId);
            throw;
        }
    }
}

๐Ÿ“ฆ Multipart Uploads (URL Generation)

Generate presigned URLs for multipart upload workflows (low-level S3 operations).

๐Ÿ”„ Multipart Upload Flow

sequenceDiagram
    participant App as Your Application
    participant Client as PresignedUrlClient
    participant S3 as S3 Service
    
    Note over App,S3: 1๏ธโƒฃ Initiate Multipart Upload
    App->>Client: InitiateMultipartUpload()
    Client->>S3: POST (initiate)
    S3-->>Client: uploadId
    Client-->>App: MultipartInitiateResponse
    
    Note over App,S3: 2๏ธโƒฃ Upload Parts (parallel)
    loop For each part (1-10,000)
        App->>Client: GetUploadPartUrl(partNumber)
        Client->>S3: GET presigned URL
        S3-->>Client: partUrl
        Client-->>App: PresignedUrlResponse
        App->>S3: PUT file chunk to partUrl
        S3-->>App: ETag header
    end
    
    Note over App,S3: 3๏ธโƒฃ Complete Upload
    App->>Client: GetCompleteMultipartUrl(parts)
    Client->>S3: POST (complete)
    S3-->>Client: completeUrl
    Client-->>App: MultipartCompleteResponse
    App->>S3: POST ETags to completeUrl
    S3-->>App: โœ… Upload Complete

๐Ÿ’ป Complete Example

public async Task<string> UploadLargeFile(string filePath, string bucket, string key)
{
    const int chunkSize = 5 * 1024 * 1024; // 5MB per part
    var parts = new List<MultipartPartInfo>();
    
    // Step 1: Initiate multipart upload
    var initiateRequest = new MultipartInitiateRequest(
        bucket: bucket,
        key: key,
        contentType: "application/octet-stream"
    );
    
    var initiateResponse = _urlService.InitiateMultipartUpload(initiateRequest);
    string uploadId = initiateResponse.UploadId;
    
    try
    {
        // Step 2: Upload parts (can be parallelized!)
        using var fileStream = File.OpenRead(filePath);
        int partNumber = 1;
        
        while (fileStream.Position < fileStream.Length)
        {
            // Get presigned URL for this part
            var partRequest = new MultipartUploadPartRequest(
                bucket: bucket,
                key: key,
                uploadId: uploadId,
                partNumber: partNumber
            );
            
            var partResponse = _urlService.GetUploadPartUrl(partRequest);
            
            // Read chunk and upload
            byte[] buffer = new byte[Math.Min(chunkSize, fileStream.Length - fileStream.Position)];
            await fileStream.ReadAsync(buffer, 0, buffer.Length);
            
            using var httpClient = new HttpClient();
            using var content = new ByteArrayContent(buffer);
            var uploadResponse = await httpClient.PutAsync(partResponse.Url, content);
            
            // S3 returns ETag in response header
            string eTag = uploadResponse.Headers.ETag.Tag;
            parts.Add(new MultipartPartInfo(partNumber, eTag));
            
            partNumber++;
        }
        
        // Step 3: Complete the upload
        var completeRequest = new MultipartCompleteRequest(
            bucket: bucket,
            key: key,
            uploadId: uploadId,
            parts: parts
        );
        
        var completeResponse = _urlService.GetCompleteMultipartUrl(completeRequest);
        
        // Finalize upload
        using var completeClient = new HttpClient();
        var finalizeResponse = await completeClient.PostAsync(completeResponse.Url, null);
        finalizeResponse.EnsureSuccessStatusCode();
        
        return $"s3://{bucket}/{key}";
    }
    catch (Exception)
    {
        // Step 4 (Error): Abort upload to clean up
        var abortRequest = new MultipartAbortRequest(bucket, key, uploadId);
        var abortResponse = _urlService.GetAbortMultipartUrl(abortRequest);
        
        using var abortClient = new HttpClient();
        await abortClient.DeleteAsync(abortResponse.Url);
        
        throw;
    }
}

๐Ÿ“Š Multipart Upload Limits

Property Min Max Notes
File Size 5 MB 5 TB Use multipart for files > 100MB
Part Size 5 MB 5 GB Except last part (can be < 5MB)
Parts 1 10,000 Part numbers must be sequential
Upload Duration - 7 days Incomplete uploads auto-deleted

๐Ÿšจ Error Handling

The library provides specific exception types for different error scenarios, making it easy to handle failures gracefully.

Exception Hierarchy

graph TD
    A[Exception] --> B[PresignedUrlException]
    B --> C[PresignedUrlBadRequestException<br/>HTTP 400]
    B --> D[PresignedUrlAuthenticationException<br/>HTTP 401]
    B --> E[PresignedUrlAuthorizationException<br/>HTTP 403]
    B --> F[PresignedUrlServiceException<br/>HTTP 500/503]
    
    style A fill:#f9f9f9
    style B fill:#fff4e1
    style C fill:#ffe1e1
    style D fill:#ffe1e1
    style E fill:#ffe1e1
    style F fill:#ffe1e1

๐ŸŽฏ Exception Types & When They Occur

Exception HTTP Code Cause Retry?
PresignedUrlBadRequestException 400 Invalid bucket/key, missing fields โŒ No
PresignedUrlAuthenticationException 401 Invalid or missing API key โŒ No
PresignedUrlAuthorizationException 403 No permission to access bucket โŒ No
PresignedUrlServiceException 500, 503 Service down, network error โœ… Yes (auto)

๐Ÿ’ป Error Handling Example

using PresignedUrlClient.Abstractions.Exceptions;

public async Task<PresignedUrlResponse> GetSecureDownloadUrl(string bucket, string key)
{
    try
    {
        var request = new PresignedUrlRequest(bucket, key, S3Operation.GetObject);
        return _urlService.GeneratePresignedUrl(request);
    }
    catch (PresignedUrlBadRequestException ex)
    {
        // 400 - Invalid request parameters
        _logger.LogError($"Invalid request: {ex.Message}");
        _logger.LogError($"Error code: {ex.ErrorCode}");
        
        // Likely a coding error - fix the request parameters
        throw new ArgumentException($"Invalid S3 parameters: {ex.Message}", ex);
    }
    catch (PresignedUrlAuthenticationException ex)
    {
        // 401 - API key is invalid or missing
        _logger.LogCritical($"Authentication failed: {ex.Message}");
        
        // Check your API key configuration
        throw new InvalidOperationException("Service authentication failed. Check API key.", ex);
    }
    catch (PresignedUrlAuthorizationException ex)
    {
        // 403 - No permission to access this bucket
        _logger.LogWarning($"Access denied to {bucket}/{key}: {ex.Message}");
        
        // User doesn't have permission - return friendly error
        return null; // Or throw custom exception
    }
    catch (PresignedUrlServiceException ex)
    {
        // 500/503 - Service error (already retried automatically)
        _logger.LogError($"Service unavailable after retries: {ex.Message}");
        _logger.LogError($"Status: {ex.StatusCode}");
        
        // Service is down - use fallback or queue for later
        await _queue.EnqueueForRetry(bucket, key);
        throw;
    }
    catch (HttpRequestException ex)
    {
        // Network error (DNS, connection refused, etc.)
        _logger.LogError($"Network error: {ex.Message}");
        throw;
    }
    catch (TaskCanceledException ex)
    {
        // Timeout after all retries
        _logger.LogError($"Request timeout after {_options.RetryCount} retries");
        throw;
    }
}

๐Ÿ” Exception Properties

All exceptions inherit from PresignedUrlException and include:

public class PresignedUrlException : Exception
{
    public string? ErrorCode { get; }        // API error code (e.g., "INVALID_BUCKET")
    public HttpStatusCode? StatusCode { get; } // HTTP status code
    public string? ResponseBody { get; }      // Full API response for debugging
}

Access exception details:

catch (PresignedUrlBadRequestException ex)
{
    Console.WriteLine($"Error Code: {ex.ErrorCode}");      // "INVALID_BUCKET_NAME"
    Console.WriteLine($"Status: {ex.StatusCode}");          // 400
    Console.WriteLine($"Message: {ex.Message}");            // Human-readable message
    Console.WriteLine($"Response: {ex.ResponseBody}");      // Full JSON response
}

๐Ÿ“‚ Sample Project Demonstrations

The sample console application (samples/PresignedUrlClient.Sample.Console) now includes real upload/download demonstrations showcasing production-ready patterns.

๐ŸŽฏ What's Included

Synchronous Examples (v1.x Compatible):

  • Example 1: Generate presigned URL for GetObject
  • Example 2: Generate presigned URL for PutObject
  • Example 3: Real HTTP Upload โญ - Actual file upload to S3 with ETag verification
  • Example 4: Real HTTP Download โญ - Actual file download from S3 with content preview and file saving
  • Example 5: Get service configuration
  • Example 6: Multipart upload workflow
  • Example 7: Error handling
  • Example 8: Complete Upload-Download-Verify Roundtrip โญ - End-to-end integration test

Async Examples (NEW in v2.0):

  • Example 9: Async URL generation with CancellationToken
  • Example 10: Concurrent async operations
  • Example 11: Async with timeout and cancellation
  • Example 12: Async configuration retrieval
  • Example 13: Async multipart workflow
  • Example 14: Large File Multipart Upload (50MB) โญ - Real S3 multipart upload with progress tracking (NEW in v2.4)

โญ New in This Release: Real Upload/Download

Example 3 - Upload File now performs:

  • โœ… Actual HTTP PUT request to S3
  • โœ… Uploads generated test content with timestamp
  • โœ… Sets proper Content-Type headers
  • โœ… Displays ETag from S3 response
  • โœ… Full status reporting
  • โœ… Creates file for Example 4 to download

Example 4 - Download File now performs:

  • โœ… Actual HTTP GET request to S3
  • โœ… Downloads file uploaded in Example 3
  • โœ… Displays content preview (first 100 chars)
  • โœ… Saves to temp directory
  • โœ… Comprehensive error handling

Example 8 - Complete Roundtrip demonstrates:

  • โœ… Upload test file with unique ID
  • โœ… Download the same file back
  • โœ… Verify content integrity byte-by-byte
  • โœ… Comprehensive summary report
  • โœ… Perfect for integration testing

Example 14 - Large File Multipart Upload (NEW in v2.4) demonstrates:

  • โœ… Generate 50MB test file with random data
  • โœ… Use IMultipartUploadService for direct S3 multipart upload
  • โœ… Automatic S3 integration (placeholder uploadId handling)
  • โœ… Upload 5 parts (10MB each) with per-part progress tracking
  • โœ… Display normalized ETags (v2.4.0 feature)
  • โœ… Complete multipart upload on S3
  • โœ… Performance metrics (upload speed, duration)
  • โœ… Error handling with automatic abort
  • โœ… Perfect for testing large file uploads!

๐ŸŽจ Enhanced Visual Feedback

The sample app now features prominent success/failure banners for each test:

  • ๐ŸŸข Green SUCCESS banners - Clear visual confirmation when examples pass
  • ๐Ÿ”ด Red FAILURE banners - Immediate visibility when examples fail
  • ๐Ÿ“Š Test Results Summary - Comprehensive report at the end showing:
    • โœ… Passed tests count
    • โŒ Failed tests count
    • โญ๏ธ Skipped tests count
    • Overall pass/fail status with color-coded summary

Example output:

================================================================================
โœ… SUCCESS: Example 3 Complete
File uploaded successfully to S3!
================================================================================

๐Ÿ“Š Test Results:

  โœ… Passed:  8
  โŒ Failed:  0
  โญ๏ธ  Skipped: 2
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  ๐Ÿ“‹ Total:   10

๐Ÿš€ Running the Samples

cd samples/PresignedUrlClient.Sample.Console
dotnet run

Configuration: Update appsettings.json with your service URL and API key.

For more details, see samples/README.md and SAMPLE_UPLOAD_DOWNLOAD_IMPLEMENTATION.md.


๐Ÿงช Testing

The library includes comprehensive test coverage with 205 tests (100% pass rate, 95%+ code coverage) across all layers.

Test Distribution

Category Tests Coverage Status
Unit Tests ~155 Models, Services, Builders, Parsing, Storage, Progress Tracking โœ… 100% Pass
Integration Tests ~27 HTTP Communication, Storage Operations (WireMock) โœ… 100% Pass
DI Tests ~23 Service Registration, Config Binding, Serialization โœ… 100% Pass
Total 205 95%+ Line Coverage โœ… 100% Pass

Running Tests

# Run all tests
dotnet test

# Run in Release mode
dotnet test --configuration Release

# Run with detailed output
dotnet test --logger "console;verbosity=detailed"

# Run specific test project
dotnet test tests/PresignedUrlClient.Core.Tests

Test Categories

๐Ÿ“‹ Unit Tests
  • โœ… Request/Response model validation
  • โœ… Constructor parameter validation
  • โœ… Configuration options validation
  • โœ… Request builder (JSON serialization)
  • โœ… Response parser (error mapping)
  • โœ… Exception hierarchy
๐ŸŒ Integration Tests (WireMock)
  • โœ… GET presigned URL generation
  • โœ… PUT presigned URL generation
  • โœ… API key header validation
  • โœ… HTTP error scenarios (400, 401, 403, 500)
  • โœ… Configuration discovery
  • โœ… Multipart upload workflows
๐Ÿ’‰ Dependency Injection Tests
  • โœ… Service registration
  • โœ… Options configuration
  • โœ… Configuration binding
  • โœ… Validation at startup
  • โœ… IResilientHttpClient integration

๐Ÿค Contributing

Contributions are welcome! This library follows:

  • โœ… SOLID Principles - Clean separation of concerns
  • โœ… TDD Approach - Tests written alongside features
  • โœ… YAGNI - Only implement what's needed (MVP scope)
  • โœ… KISS - Simple, straightforward implementations

Development Guidelines

  1. Run tests before committing: dotnet test
  2. Follow existing patterns: Check PLANNING.md for architecture decisions
  3. Add tests for new features: Maintain 100% pass rate
  4. Update documentation: Keep README in sync with code changes

๐Ÿ“„ License

This project is proprietary and closed source. All rights reserved.

See the LICENSE file for full terms and conditions.

โš ๏ธ NOTICE: Unauthorized copying, distribution, or use of this software is strictly prohibited.


๐Ÿ™ Acknowledgments

  • ResilientHttpClient.Core - Provides the resilience layer (GitHub)
  • WireMock.Net - Used for integration testing
  • FluentAssertions & xUnit - Testing framework

๐Ÿ“š Additional Resources


<div align="center">

Built with โค๏ธ using .NET Standard 2.1

Made for developers who need reliable, resilient S3 presigned URL generation ๐Ÿš€

โฌ† Back to Top

</div>

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on PresignedUrlClient.Serialization.SystemTextJson:

Package Downloads
PresignedUrlClient.DependencyInjection

Dependency injection extensions for the PresignedUrlClient library. Provides easy registration of services with Microsoft.Extensions.DependencyInjection.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.4.1 357 11/17/2025
2.4.0 347 11/17/2025
2.3.0 299 11/13/2025
2.2.0 215 10/20/2025
2.1.0 212 10/15/2025

v2.0.0: Compatible with async methods. No breaking changes.