RepletoryLib.FileStorage.Aws
1.0.0
dotnet add package RepletoryLib.FileStorage.Aws --version 1.0.0
NuGet\Install-Package RepletoryLib.FileStorage.Aws -Version 1.0.0
<PackageReference Include="RepletoryLib.FileStorage.Aws" Version="1.0.0" />
<PackageVersion Include="RepletoryLib.FileStorage.Aws" Version="1.0.0" />
<PackageReference Include="RepletoryLib.FileStorage.Aws" />
paket add RepletoryLib.FileStorage.Aws --version 1.0.0
#r "nuget: RepletoryLib.FileStorage.Aws, 1.0.0"
#:package RepletoryLib.FileStorage.Aws@1.0.0
#addin nuget:?package=RepletoryLib.FileStorage.Aws&version=1.0.0
#tool nuget:?package=RepletoryLib.FileStorage.Aws&version=1.0.0
RepletoryLib.FileStorage.Aws
AWS S3 file storage implementation with presigned URLs, bulk operations, multi-tenant path strategies, and S3-compatible service support (LocalStack, MinIO).
Part of the RepletoryLib ecosystem -- standalone, reusable .NET 10 libraries with zero business logic.
Overview
RepletoryLib.FileStorage.Aws provides a full AWS S3 implementation of IFileStorageService from the FileStorage.Abstractions package. It supports uploading (single and bulk), downloading (stream and byte array), deleting (single and bulk), copying, moving, metadata retrieval, paginated listing, presigned URL generation, and public URL construction.
The package includes S3FilePathStrategy for generating organized S3 object keys using configurable path templates with multi-tenant and date-based partitioning, and automatic content type inference from file extensions.
Key Features
S3FileStorageService-- FullIFileStorageServiceimplementation backed by AWS S3- Upload -- Single and bulk uploads with automatic content type inference, ACL mapping, and custom metadata
- Download -- Stream-based and byte array downloads
- Delete -- Single and bulk deletes using S3 multi-object delete API
- Copy & Move -- Server-side copy; move is copy + delete
- Presigned URLs -- Configurable expiry and HTTP method (GET, PUT, DELETE, HEAD)
- Public URLs -- Virtual-hosted-style and path-style URL construction
S3FilePathStrategy-- Token-based path generation with{tenant},{year},{month},{day},{filename}- S3-compatible -- Works with LocalStack, MinIO, and other S3-compatible services via
ServiceUrlandForcePathStyle - Visibility control -- Automatic
S3CannedACLmapping fromFileVisibilityenum - Content type inference -- 30+ MIME type mappings for common file extensions
Installation
dotnet add package RepletoryLib.FileStorage.Aws
Or add to your .csproj:
<PackageReference Include="RepletoryLib.FileStorage.Aws" Version="1.0.0" />
Note: RepletoryLib packages are published to a local BaGet feed. See the main repository README for feed configuration.
Dependencies
| Package | Type |
|---|---|
RepletoryLib.FileStorage.Abstractions |
RepletoryLib |
AWSSDK.S3 |
NuGet (3.7.410.1) |
Microsoft.Extensions.Configuration.Abstractions |
NuGet (10.0.0) |
Microsoft.Extensions.Configuration.Binder |
NuGet (10.0.0) |
Microsoft.Extensions.DependencyInjection.Abstractions |
NuGet (10.0.0) |
Microsoft.Extensions.Logging.Abstractions |
NuGet (10.0.0) |
Microsoft.Extensions.Options |
NuGet (10.0.0) |
Microsoft.Extensions.Options.ConfigurationExtensions |
NuGet (10.0.0) |
Prerequisites
- AWS account with an S3 bucket created, or an S3-compatible service (LocalStack, MinIO)
- AWS credentials configured via
appsettings.json, environment variables, or IAM role
Quick Start
using RepletoryLib.FileStorage.Aws;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRepletoryAwsFileStorage(builder.Configuration);
{
"FileStorage": {
"MaxFileSizeBytes": 104857600,
"AllowedExtensions": [],
"DefaultVisibility": "Private",
"PathTemplate": "{tenant}/{year}/{month}/{filename}",
"PreserveOriginalFileName": true,
"AwsS3": {
"BucketName": "my-app-files",
"Region": "af-south-1",
"AccessKey": "AKIAIOSFODNN7EXAMPLE",
"SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"PresignedUrlExpiryMinutes": 60
}
}
}
Configuration
AwsS3Options
| Property | Type | Default | Description |
|---|---|---|---|
BucketName |
string |
"" |
Name of the S3 bucket |
Region |
string |
"af-south-1" |
AWS region for the S3 bucket |
AccessKey |
string? |
null |
AWS access key (uses default credential chain if null) |
SecretKey |
string? |
null |
AWS secret key (uses default credential chain if null) |
ServiceUrl |
string? |
null |
Custom endpoint for S3-compatible services (LocalStack, MinIO) |
ForcePathStyle |
bool |
false |
Use path-style addressing (required for some S3-compatible services) |
PresignedUrlExpiryMinutes |
int |
60 |
Default presigned URL expiry in minutes |
Section name: "FileStorage:AwsS3"
FileStorageOptions (shared)
| Property | Type | Default | Description |
|---|---|---|---|
MaxFileSizeBytes |
long |
104857600 (100 MB) |
Maximum allowed file size in bytes |
AllowedExtensions |
List<string> |
[] (all allowed) |
Allowed file extensions |
DefaultVisibility |
FileVisibility |
Private |
Default access visibility for uploads |
PathTemplate |
string |
"{tenant}/{year}/{month}/{filename}" |
Token-based path template for S3 object keys |
PreserveOriginalFileName |
bool |
true |
When false, generates GUID-based file names |
Section name: "FileStorage"
LocalStack / MinIO Configuration
{
"FileStorage": {
"AwsS3": {
"BucketName": "test-bucket",
"Region": "us-east-1",
"ServiceUrl": "http://localhost:4566",
"ForcePathStyle": true,
"AccessKey": "test",
"SecretKey": "test"
}
}
}
Usage Examples
Upload a File
using RepletoryLib.FileStorage.Abstractions.Enums;
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
public class DocumentService
{
private readonly IFileStorageService _storage;
public DocumentService(IFileStorageService storage) => _storage = storage;
public async Task<FileUploadResult> UploadAsync(Stream content, string fileName, Guid tenantId)
{
var request = new FileUploadRequest
{
Content = content,
FileName = fileName,
Folder = "documents",
Visibility = FileVisibility.Private,
Metadata = new Dictionary<string, string>
{
["tenant-id"] = tenantId.ToString(),
["uploaded-by"] = "document-service"
}
};
var result = await _storage.UploadAsync(request);
if (!result.Success)
{
throw new InvalidOperationException($"Upload failed: {result.Error}");
}
return result;
// result.FileKey = "documents/default/2026/03/report.pdf"
}
}
Upload Public Images
public async Task<string> UploadAvatarAsync(Stream imageStream, string fileName)
{
var result = await _storage.UploadAsync(new FileUploadRequest
{
Content = imageStream,
FileName = fileName,
ContentType = "image/jpeg",
Folder = "avatars",
Visibility = FileVisibility.Public
});
// Public files get a URL immediately
// e.g., "https://my-app-files.s3.af-south-1.amazonaws.com/avatars/default/2026/03/photo.jpg"
return result.Url;
}
Bulk Upload
public async Task<BulkUploadResult> UploadBatchAsync(List<IFormFile> files)
{
var requests = files.Select(f => new FileUploadRequest
{
Content = f.OpenReadStream(),
FileName = f.FileName,
ContentType = f.ContentType,
Folder = "batch-imports"
});
var result = await _storage.UploadBulkAsync(requests);
// Partial success -- individual files may fail independently
if (result.FailureCount > 0)
{
foreach (var failed in result.Results.Where(r => !r.Success))
{
_logger.LogWarning("Upload failed: {Error}", failed.Error);
}
}
return result;
}
Download Files
// Download as stream (caller must dispose)
public async Task<Stream> DownloadAsync(string fileKey)
{
return await _storage.DownloadAsync(fileKey);
}
// Download as byte array
public async Task<byte[]> DownloadBytesAsync(string fileKey)
{
return await _storage.DownloadBytesAsync(fileKey);
}
Copy, Move, and Delete
// Server-side copy (no data transfer through your app)
await _storage.CopyAsync(
"documents/default/2026/03/report.pdf",
"backups/default/2026/03/report.pdf");
// Move (copy + delete source)
await _storage.MoveAsync(
"temp/uploads/draft.docx",
"documents/default/2026/03/final.docx");
// Delete single file
await _storage.DeleteAsync("temp/uploads/draft.docx");
// Bulk delete (uses S3 multi-object delete API)
await _storage.DeleteBulkAsync(new[]
{
"temp/file1.pdf",
"temp/file2.pdf",
"temp/file3.pdf"
});
Get Metadata
var metadata = await _storage.GetMetadataAsync("documents/default/2026/03/report.pdf");
Console.WriteLine($"File: {metadata.FileName}"); // "report.pdf"
Console.WriteLine($"Size: {metadata.SizeBytes} bytes"); // 1048576
Console.WriteLine($"Type: {metadata.ContentType}"); // "application/pdf"
Console.WriteLine($"Created: {metadata.CreatedAt}"); // 2026-03-15T10:30:00Z
Console.WriteLine($"Custom: {metadata.Metadata["uploaded-by"]}"); // "document-service"
List Files with Pagination
var request = new FileListRequest
{
Prefix = "documents/default/2026/",
MaxResults = 100
};
var result = await _storage.ListAsync(request);
foreach (var file in result.Files)
{
Console.WriteLine($"{file.FileKey} - {file.SizeBytes} bytes");
}
// Continue paginating
while (result.IsTruncated)
{
request.ContinuationToken = result.ContinuationToken;
result = await _storage.ListAsync(request);
foreach (var file in result.Files)
{
Console.WriteLine($"{file.FileKey} - {file.SizeBytes} bytes");
}
}
Generate Presigned URLs
// Presigned download URL (2 hours)
var downloadUrl = await _storage.GetPresignedUrlAsync(new PresignedUrlRequest
{
FileKey = "documents/default/2026/03/report.pdf",
Expiry = TimeSpan.FromHours(2),
Method = HttpMethod.Get
});
// Presigned upload URL (15 minutes)
var uploadUrl = await _storage.GetPresignedUrlAsync(new PresignedUrlRequest
{
FileKey = "uploads/new-document.pdf",
Expiry = TimeSpan.FromMinutes(15),
Method = HttpMethod.Put
});
// Public URL for publicly-visible files
var publicUrl = await _storage.GetPublicUrlAsync("avatars/default/2026/03/photo.jpg");
// "https://my-app-files.s3.af-south-1.amazonaws.com/avatars/default/2026/03/photo.jpg"
Check Existence
bool exists = await _storage.ExistsAsync("documents/default/2026/03/report.pdf");
if (!exists)
{
throw new FileNotFoundException("Document not found in S3");
}
API Reference
ServiceCollectionExtensions
| Method | Returns | Description |
|---|---|---|
AddRepletoryAwsFileStorage(configuration) |
IServiceCollection |
Registers IAmazonS3, IFilePathStrategy (singleton), and IFileStorageService (scoped) |
S3FileStorageService (implements IFileStorageService)
| Method | Returns | Description |
|---|---|---|
UploadAsync(request, ct) |
Task<FileUploadResult> |
Upload a file to S3 with ACL, content type, and custom metadata |
UploadBulkAsync(requests, ct) |
Task<BulkUploadResult> |
Upload multiple files sequentially with partial-success semantics |
DownloadAsync(fileKey, ct) |
Task<Stream> |
Download a file as a stream from S3 |
DownloadBytesAsync(fileKey, ct) |
Task<byte[]> |
Download a file as a byte array from S3 |
DeleteAsync(fileKey, ct) |
Task |
Delete a single object from S3 |
DeleteBulkAsync(fileKeys, ct) |
Task |
Delete multiple objects using S3 multi-object delete |
ExistsAsync(fileKey, ct) |
Task<bool> |
Check if an object exists via HEAD request |
CopyAsync(sourceKey, destinationKey, ct) |
Task |
Server-side copy within the same bucket |
MoveAsync(sourceKey, destinationKey, ct) |
Task |
Copy then delete (move within same bucket) |
GetMetadataAsync(fileKey, ct) |
Task<FileMetadata> |
Retrieve object metadata via HEAD request |
ListAsync(request, ct) |
Task<FileListResult> |
List objects with prefix, delimiter, and continuation token |
GetPresignedUrlAsync(request, ct) |
Task<string> |
Generate a presigned URL for GET, PUT, DELETE, or HEAD |
GetPublicUrlAsync(fileKey, ct) |
Task<string> |
Build a public URL (virtual-hosted or path-style) |
S3FilePathStrategy (implements IFilePathStrategy)
| Method | Returns | Description |
|---|---|---|
GeneratePath(context) |
string |
Generate an S3 object key by replacing tokens in the path template |
Registered Services
| Interface | Implementation | Lifetime |
|---|---|---|
IAmazonS3 |
AmazonS3Client |
Singleton |
IFilePathStrategy |
S3FilePathStrategy |
Singleton |
IFileStorageService |
S3FileStorageService |
Scoped |
Integration with Other RepletoryLib Packages
| Package | Relationship |
|---|---|
RepletoryLib.FileStorage.Abstractions |
Direct dependency -- IFileStorageService, IFilePathStrategy, all models and options |
RepletoryLib.FileStorage.Local |
Alternative provider -- swap to local filesystem for development |
RepletoryLib.Common |
Transitive dependency via Abstractions |
RepletoryLib.Api |
Use IFileStorageService in API controllers for file upload/download endpoints |
Testing
using NSubstitute;
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
public class DocumentServiceTests
{
[Fact]
public async Task UploadAsync_calls_storage_and_returns_file_key()
{
// Arrange
var storage = Substitute.For<IFileStorageService>();
storage.UploadAsync(Arg.Any<FileUploadRequest>(), Arg.Any<CancellationToken>())
.Returns(new FileUploadResult
{
Success = true,
FileKey = "documents/default/2026/03/report.pdf",
Url = "https://my-bucket.s3.af-south-1.amazonaws.com/documents/default/2026/03/report.pdf"
});
var service = new DocumentService(storage);
// Act
using var stream = new MemoryStream("test content"u8.ToArray());
var result = await service.UploadAsync(stream, "report.pdf", Guid.NewGuid());
// Assert
result.Success.Should().BeTrue();
result.FileKey.Should().Contain("report.pdf");
await storage.Received(1).UploadAsync(
Arg.Is<FileUploadRequest>(r =>
r.FileName == "report.pdf" &&
r.Folder == "documents"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExistsAsync_returns_false_for_missing_file()
{
// Arrange
var storage = Substitute.For<IFileStorageService>();
storage.ExistsAsync("nonexistent/file.pdf", Arg.Any<CancellationToken>())
.Returns(false);
// Act
var exists = await storage.ExistsAsync("nonexistent/file.pdf");
// Assert
exists.Should().BeFalse();
}
}
Troubleshooting
| Issue | Solution |
|---|---|
AmazonS3Exception: Access Denied |
Verify AccessKey/SecretKey in configuration, or ensure the IAM role has s3:PutObject, s3:GetObject, s3:DeleteObject permissions |
AmazonS3Exception: NoSuchBucket |
Ensure BucketName in AwsS3Options matches an existing bucket in the configured Region |
| Presigned URLs return 403 | Check that the URL has not expired; increase Expiry in PresignedUrlRequest or PresignedUrlExpiryMinutes in options |
| Public URLs return 403 | Ensure the file was uploaded with FileVisibility.Public and the bucket policy allows public reads |
| LocalStack/MinIO connection refused | Set ServiceUrl to the correct endpoint (e.g., http://localhost:4566) and ForcePathStyle to true |
Content type is application/octet-stream |
Set ContentType explicitly on FileUploadRequest, or ensure the file has a recognized extension |
| Path template produces unexpected keys | Verify PathTemplate tokens in FileStorageOptions; supported tokens: {tenant}, {year}, {month}, {day}, {filename} |
| Bulk upload partially fails | Check BulkUploadResult.Results for per-file Error messages; individual failures do not affect other uploads |
License
This project is licensed under the MIT License.
Copyright (c) 2024-2026 Repletory.
For complete documentation, infrastructure setup, and configuration reference, see the RepletoryLib main repository.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- AWSSDK.S3 (>= 3.7.410.1)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
- RepletoryLib.FileStorage.Abstractions (>= 1.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 74 | 3/2/2026 |