RepletoryLib.FileStorage.Abstractions
Provider-agnostic file storage interfaces, models, and options for upload, download, delete, copy, move, listing, and presigned URL generation.
Part of the RepletoryLib ecosystem -- standalone, reusable .NET 10 libraries with zero business logic.

Overview
RepletoryLib.FileStorage.Abstractions defines the core contracts and models for file storage operations. It provides IFileStorageService for full CRUD operations on files (upload, download, delete, copy, move, metadata, listing, presigned URLs) and IFilePathStrategy for generating organized storage paths with multi-tenant and date-based partitioning.
This package contains no implementations -- it is the abstraction layer that concrete providers (AWS S3, local filesystem, Azure Blob, etc.) implement. Reference this package in your application code to program against interfaces, then swap providers without changing business logic.
Key Features
IFileStorageService -- Full file storage contract: upload, bulk upload, download (stream and bytes), delete, bulk delete, copy, move, metadata, listing, presigned URLs, and public URLs
IFilePathStrategy -- Pluggable strategy for generating organized storage paths from contextual information
FileUploadRequest / FileUploadResult -- Request/result models with content type inference, visibility control, and custom metadata
BulkUploadResult -- Partial-success model for bulk uploads with per-file outcomes
FileMetadata -- Rich metadata model including size, timestamps, content type, visibility, and custom key-value pairs
FileListRequest / FileListResult -- Paginated file listing with prefix filtering, delimiter support, and continuation tokens
PresignedUrlRequest -- Configurable presigned URL generation with expiry and HTTP method control
FileVisibility enum -- Public vs. Private access control
FileStorageOptions -- Shared configuration for max file size, allowed extensions, default visibility, and path templates
Installation
dotnet add package RepletoryLib.FileStorage.Abstractions
Or add to your .csproj:
<PackageReference Include="RepletoryLib.FileStorage.Abstractions" 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.Common |
RepletoryLib |
Quick Start
1. Reference the abstractions in your service layer
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> UploadDocumentAsync(Stream content, string fileName)
{
var request = new FileUploadRequest
{
Content = content,
FileName = fileName,
Folder = "documents"
};
return await _storage.UploadAsync(request);
}
}
2. Register a concrete provider in Program.cs
// AWS S3 provider
using RepletoryLib.FileStorage.Aws;
builder.Services.AddRepletoryAwsFileStorage(builder.Configuration);
// -- OR -- Local filesystem provider
using RepletoryLib.FileStorage.Local;
builder.Services.AddRepletoryLocalFileStorage(builder.Configuration);
Configuration
FileStorageOptions
| Property |
Type |
Default |
Description |
MaxFileSizeBytes |
long |
104857600 (100 MB) |
Maximum allowed file size in bytes |
AllowedExtensions |
List<string> |
[] (all allowed) |
Allowed file extensions (e.g., [".jpg", ".png", ".pdf"]). Empty list allows all |
DefaultVisibility |
FileVisibility |
Private |
Default access visibility for uploaded files |
PathTemplate |
string |
"{tenant}/{year}/{month}/{filename}" |
Token-based template for organizing storage paths |
PreserveOriginalFileName |
bool |
true |
When false, a GUID-based file name is generated |
Section name: "FileStorage"
{
"FileStorage": {
"MaxFileSizeBytes": 52428800,
"AllowedExtensions": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".docx"],
"DefaultVisibility": "Private",
"PathTemplate": "{tenant}/{year}/{month}/{day}/{filename}",
"PreserveOriginalFileName": true
}
}
Path Template Tokens
| Token |
Description |
Example Value |
{tenant} |
Tenant ID from FilePathContext.TenantId, or "default" |
a1b2c3d4-... |
{year} |
Upload year |
2026 |
{month} |
Upload month (zero-padded) |
03 |
{day} |
Upload day (zero-padded) |
15 |
{filename} |
Original or GUID-based file name |
report.pdf |
Usage Examples
Upload a File
using RepletoryLib.FileStorage.Abstractions.Enums;
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
public class ImageService
{
private readonly IFileStorageService _storage;
public ImageService(IFileStorageService storage) => _storage = storage;
public async Task<FileUploadResult> UploadImageAsync(Stream imageStream, string fileName, Guid tenantId)
{
var request = new FileUploadRequest
{
Content = imageStream,
FileName = fileName,
ContentType = "image/jpeg",
Folder = "avatars",
Visibility = FileVisibility.Public,
Metadata = new Dictionary<string, string>
{
["uploaded-by"] = "image-service",
["tenant-id"] = tenantId.ToString()
}
};
return await _storage.UploadAsync(request);
}
}
Bulk Upload
public async Task<BulkUploadResult> UploadBatchAsync(IEnumerable<(Stream Content, string FileName)> files)
{
var requests = files.Select(f => new FileUploadRequest
{
Content = f.Content,
FileName = f.FileName,
Folder = "batch-imports"
});
var result = await _storage.UploadBulkAsync(requests);
// Partial success -- check individual results
foreach (var r in result.Results.Where(r => !r.Success))
{
_logger.LogWarning("Failed to upload: {Error}", r.Error);
}
_logger.LogInformation("Bulk upload: {Success}/{Total} succeeded",
result.SuccessCount, result.Results.Count);
return result;
}
Download Files
// Download as stream (caller must dispose)
await using var stream = await _storage.DownloadAsync("default/2026/03/report.pdf");
await stream.CopyToAsync(httpResponse.Body);
// Download as byte array
byte[] bytes = await _storage.DownloadBytesAsync("default/2026/03/report.pdf");
Copy, Move, and Delete
// Copy a file
await _storage.CopyAsync("originals/photo.jpg", "backups/photo.jpg");
// Move a file (copy + delete source)
await _storage.MoveAsync("temp/upload.pdf", "documents/final.pdf");
// Delete a single file
await _storage.DeleteAsync("temp/upload.pdf");
// Bulk delete
await _storage.DeleteBulkAsync(new[] { "temp/file1.txt", "temp/file2.txt", "temp/file3.txt" });
// Check if file exists
bool exists = await _storage.ExistsAsync("default/2026/03/report.pdf");
// Get file metadata
FileMetadata metadata = await _storage.GetMetadataAsync("default/2026/03/report.pdf");
Console.WriteLine($"File: {metadata.FileName}");
Console.WriteLine($"Size: {metadata.SizeBytes} bytes");
Console.WriteLine($"Type: {metadata.ContentType}");
Console.WriteLine($"Created: {metadata.CreatedAt}");
Console.WriteLine($"Visibility: {metadata.Visibility}");
var request = new FileListRequest
{
Prefix = "default/2026/03/",
Delimiter = "/",
MaxResults = 50
};
var result = await _storage.ListAsync(request);
foreach (var file in result.Files)
{
Console.WriteLine($"{file.FileKey} ({file.SizeBytes} bytes)");
}
// Paginate through results
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
// Generate a download URL valid for 2 hours
var url = await _storage.GetPresignedUrlAsync(new PresignedUrlRequest
{
FileKey = "default/2026/03/report.pdf",
Expiry = TimeSpan.FromHours(2),
Method = HttpMethod.Get
});
// Generate an upload URL valid for 15 minutes
var uploadUrl = await _storage.GetPresignedUrlAsync(new PresignedUrlRequest
{
FileKey = "uploads/new-document.pdf",
Expiry = TimeSpan.FromMinutes(15),
Method = HttpMethod.Put
});
Custom File Path Strategy
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
public class CustomFilePathStrategy : IFilePathStrategy
{
public string GeneratePath(FilePathContext context)
{
var tenant = context.TenantId?.ToString() ?? "shared";
var date = context.UploadDate;
var uniqueName = $"{Guid.NewGuid()}{Path.GetExtension(context.FileName)}";
var path = $"{tenant}/{date:yyyy}/{date:MM}/{date:dd}/{uniqueName}";
if (!string.IsNullOrWhiteSpace(context.Folder))
{
path = $"{context.Folder.TrimEnd('/')}/{path}";
}
return path;
}
}
// Register in DI (overrides the provider's default strategy)
builder.Services.AddSingleton<IFilePathStrategy, CustomFilePathStrategy>();
API Reference
IFileStorageService
| Method |
Returns |
Description |
UploadAsync(request, ct) |
Task<FileUploadResult> |
Upload a single file |
UploadBulkAsync(requests, ct) |
Task<BulkUploadResult> |
Upload multiple files with partial-success semantics |
DownloadAsync(fileKey, ct) |
Task<Stream> |
Download a file as a stream (caller disposes) |
DownloadBytesAsync(fileKey, ct) |
Task<byte[]> |
Download a file as a byte array |
DeleteAsync(fileKey, ct) |
Task |
Delete a single file |
DeleteBulkAsync(fileKeys, ct) |
Task |
Delete multiple files |
ExistsAsync(fileKey, ct) |
Task<bool> |
Check if a file exists |
CopyAsync(sourceKey, destinationKey, ct) |
Task |
Copy a file to a new location |
MoveAsync(sourceKey, destinationKey, ct) |
Task |
Move a file (copy + delete source) |
GetMetadataAsync(fileKey, ct) |
Task<FileMetadata> |
Get file metadata |
ListAsync(request, ct) |
Task<FileListResult> |
List files with prefix filtering and pagination |
GetPresignedUrlAsync(request, ct) |
Task<string> |
Generate a temporary presigned URL |
GetPublicUrlAsync(fileKey, ct) |
Task<string> |
Get the public URL for a file |
IFilePathStrategy
| Method |
Returns |
Description |
GeneratePath(context) |
string |
Generate a storage path from contextual information |
FileUploadRequest
| Property |
Type |
Required |
Description |
Content |
Stream |
Yes |
File content stream |
FileName |
string |
Yes |
Original file name |
ContentType |
string? |
No |
MIME type (inferred from extension if null) |
Folder |
string? |
No |
Optional folder prefix for organizing the file |
Visibility |
FileVisibility? |
No |
Access visibility (defaults to FileStorageOptions.DefaultVisibility) |
Metadata |
Dictionary<string, string>? |
No |
Custom metadata key-value pairs |
FileUploadResult
| Property |
Type |
Description |
Success |
bool |
Whether the upload succeeded |
FileKey |
string |
Unique storage key for the uploaded file |
Url |
string |
Public or presigned URL to access the file |
Error |
string? |
Error message if the upload failed |
Metadata |
FileMetadata? |
Metadata of the uploaded file |
BulkUploadResult
| Property |
Type |
Description |
Results |
List<FileUploadResult> |
Per-file upload results |
SuccessCount |
int |
Number of successful uploads (computed) |
FailureCount |
int |
Number of failed uploads (computed) |
| Property |
Type |
Description |
FileKey |
string |
Unique storage key |
FileName |
string |
Original file name |
ContentType |
string |
MIME content type |
SizeBytes |
long |
File size in bytes |
CreatedAt |
DateTime |
UTC creation timestamp |
LastModifiedAt |
DateTime? |
UTC last-modified timestamp |
Visibility |
FileVisibility |
Access visibility |
Metadata |
Dictionary<string, string> |
Custom metadata key-value pairs |
FileListRequest
| Property |
Type |
Default |
Description |
Prefix |
string? |
null |
Key prefix to filter files |
Delimiter |
string? |
null |
Delimiter for grouping keys (e.g., "/") |
MaxResults |
int |
1000 |
Maximum files to return |
ContinuationToken |
string? |
null |
Continuation token for pagination |
FileListResult
| Property |
Type |
Description |
Files |
List<FileMetadata> |
File metadata entries |
ContinuationToken |
string? |
Token for next page (null if no more pages) |
IsTruncated |
bool |
Whether results were truncated |
PresignedUrlRequest
| Property |
Type |
Default |
Description |
FileKey |
string |
(required) |
Storage key of the file |
Expiry |
TimeSpan |
1 hour |
Duration the URL remains valid |
Method |
HttpMethod |
GET |
HTTP method the URL is valid for |
FilePathContext
| Property |
Type |
Default |
Description |
FileName |
string |
(required) |
Original file name |
TenantId |
Guid? |
null |
Tenant ID for multi-tenant path isolation |
UploadDate |
DateTime |
DateTime.UtcNow |
Upload date for date-based path organization |
Folder |
string? |
null |
Optional folder prefix |
FileVisibility Enum
| Value |
Description |
Public |
File is publicly accessible via URL without authentication |
Private |
File requires authentication or a presigned URL to access |
Integration with Other RepletoryLib Packages
| Package |
Relationship |
RepletoryLib.Common |
Direct dependency -- shared base types |
RepletoryLib.FileStorage.Aws |
AWS S3 implementation of IFileStorageService and IFilePathStrategy |
RepletoryLib.FileStorage.Local |
Local filesystem implementation of IFileStorageService and IFilePathStrategy |
RepletoryLib.Api |
Use IFileStorageService in API controllers for file upload endpoints |
Testing
using NSubstitute;
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
public class DocumentServiceTests
{
[Fact]
public async Task UploadDocumentAsync_returns_success_result()
{
// 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 = "/files/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.UploadDocumentAsync(stream, "report.pdf");
// 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>());
}
}
Troubleshooting
| Issue |
Solution |
FileUploadResult.Success is false |
Check the Error property for the failure reason; common causes are file size limits or I/O errors |
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 match the supported tokens: {tenant}, {year}, {month}, {day}, {filename} |
AllowedExtensions not filtering |
An empty list allows all extensions; populate it with the extensions you want to permit (e.g., [".jpg", ".pdf"]) |
| Files not organized by tenant |
Set FilePathContext.TenantId when building upload requests; without it, the path defaults to "default" |
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.