Storage operations (upload/download/multipart) for the PresignedUrlClient library. Provides first-class file operations with progress tracking, automatic multipart upload, and parallel part uploads.
$ dotnet add package PresignedUrlClient.StorageA 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.
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!
IProgress<UploadProgress>) + Simple (IProgress<double>) options (NEW v2.2)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
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
# Minimum installation for presigned URL generation
dotnet add reference path/to/PresignedUrlClient.DependencyInjection
# Full installation including storage operations
dotnet add reference path/to/PresignedUrlClient.DependencyInjection
dotnet add reference path/to/PresignedUrlClient.Storage.DependencyInjection
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();
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"]!;
});
}
}
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);
}
}
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
});
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")
);
| 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) |
The library includes built-in resilience patterns powered by ResilientHttpClient.Core to handle transient failures and prevent cascading errors.
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:
TaskCanceledException)HttpRequestException)Not Retried:
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:
// 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
});
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"
}
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)
}
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
All service methods now have async counterparts with CancellationToken support for better scalability and responsiveness.
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;
}
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];
}
public async Task<ConfigurationResponse> GetServiceInfoAsync(CancellationToken cancellationToken = default)
{
// Non-blocking configuration fetch
var config = await _urlService.GetConfigurationAsync(cancellationToken);
return config;
}
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;
}
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");
}
}
Complete file upload and download operations with universal progress tracking, built on top of presigned URLs.
What's New in v2.2.0:
PercentProgress option for easy progress barsusing 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;
}
}
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;
}
The library provides two ways to track upload/download progress:
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}");
}
})
};
For simple scenarios where you only need the completion percentage:
var options = new UploadOptions
{
PercentProgress = new Progress<double>(percent =>
Console.WriteLine($"Upload: {percent:F1}%"))
};
You can use both progress reporters simultaneously:
var options = new UploadOptions
{
Progress = richProgressHandler, // For detailed UI/logging
PercentProgress = simpleProgressBar // For progress bar
};
Available for:
UploadOptions.Progress / UploadOptions.PercentProgress)DownloadOptions.Progress / DownloadOptions.PercentProgress)IMultipartUploadService.UploadPartAsync with IProgress<long>)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;
}
}
}
Generate presigned URLs for multipart upload workflows (low-level S3 operations).
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
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;
}
}
| 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 |
The library provides specific exception types for different error scenarios, making it easy to handle failures gracefully.
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 | 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) |
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;
}
}
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
}
The sample console application (samples/PresignedUrlClient.Sample.Console) now includes real upload/download demonstrations showcasing production-ready patterns.
Synchronous Examples (v1.x Compatible):
Async Examples (NEW in v2.0):
Example 3 - Upload File now performs:
Example 4 - Download File now performs:
Example 8 - Complete Roundtrip demonstrates:
Example 14 - Large File Multipart Upload (NEW in v2.4) demonstrates:
The sample app now features prominent success/failure banners for each test:
Example output:
================================================================================
✅ SUCCESS: Example 3 Complete
File uploaded successfully to S3!
================================================================================
📊 Test Results:
✅ Passed: 8
❌ Failed: 0
⏭️ Skipped: 2
────────────────────
📋 Total: 10
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.
The library includes comprehensive test coverage with 205 tests (100% pass rate, 95%+ code coverage) across all layers.
| 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 |
# 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
Contributions are welcome! This library follows:
dotnet testPLANNING.md for architecture decisionsThis 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.
Built with ❤️ using .NET Standard 2.1
Made for developers who need reliable, resilient S3 presigned URL generation 🚀