CL.StorageS3
S3-compatible object storage — one API across Amazon S3, MinIO, and Cloudflare R2.
CL.StorageS3 adds object storage to a CodeLogic 4 application over AWSSDK.S3. You configure one or more named connections (each pointing at an S3, MinIO, or R2 endpoint), then perform bucket and object operations through an S3StorageService scoped to a connection. Every operation returns the framework Result<T> so exceptions are caught and wrapped — check IsSuccess / IsFailure and read Error?.Message rather than using try/catch. The two existence checks are the exception: they return a plain Task<bool>.
| Package | CodeLogic.StorageS3 |
| Library class | CL.StorageS3.StorageS3Library |
| Config file | config.storages3.json |
| Dependencies | AWSSDK.S3 4.x |
Install & load
dotnet add package CodeLogic.StorageS3
using CL.StorageS3;
await Libraries.LoadAsync<StorageS3Library>(); // register before ConfigureAsync()
await CodeLogic.ConfigureAsync();
await CodeLogic.StartAsync();
var storage = Libraries.Get<StorageS3Library>();
var s3 = storage.DefaultService; // the "Default" connection
var r2 = storage.GetService("R2"); // a named connection
DefaultService is shorthand for GetService("Default"). Each call returns a fresh S3StorageService bound to that connection's underlying client. The connection IDs you can pass are exactly the connectionId values from configuration.
The multi-provider story
The library speaks one protocol — S3 — but each provider needs slightly different addressing and signing. You select a provider entirely through configuration; the code you write is identical across all three.
| Setting | AWS S3 | MinIO | Cloudflare R2 |
|---|---|---|---|
serviceUrl |
https://s3.amazonaws.com |
http://localhost:9000 |
https://<account>.r2.cloudflarestorage.com |
region |
your region (e.g. us-east-1) |
signing region | auto |
forcePathStyle |
false |
true |
false |
useHttps |
true |
false (local) |
true |
disablePayloadSigning |
false |
false |
true |
region controls request signing even when serviceUrl is set; Cloudflare R2 uses auto. forcePathStyle: true selects host/bucket/key addressing (required by MinIO and most non-AWS services); false selects bucket.host/key virtual-host addressing (AWS and R2). disablePayloadSigning: true turns off streaming AWS4 payload signing, which Cloudflare R2 rejects.
Bucket operations
Result<bool> created = await s3.CreateBucketAsync("my-bucket");
Result<bool> deleted = await s3.DeleteBucketAsync("old-bucket"); // bucket must be empty
Result<List<BucketInfo>> buckets = await s3.ListBucketsAsync();
foreach (var b in buckets.Value!)
Console.WriteLine($"{b.Name} (created {b.CreationDate:u})");
bool exists = await s3.BucketExistsAsync("my-bucket"); // plain bool, no Result
CreateBucketAsync publishes a BucketCreatedEvent on success.
Uploading objects
PutObjectAsync has two overloads — one for a byte[] and one for a Stream. Both take an optional UploadOptions and return Result<S3ObjectInfo> describing the stored object (its size, ETag, content type, metadata, and public URL are read back after the write).
// From bytes
byte[] bytes = await File.ReadAllBytesAsync("report.pdf");
Result<S3ObjectInfo> a = await s3.PutObjectAsync(
"my-bucket", "docs/report.pdf", bytes,
new UploadOptions
{
ContentType = "application/pdf",
CacheControl = "public, max-age=3600",
ContentDisposition = "inline; filename=\"report.pdf\"",
StorageClass = "STANDARD", // or REDUCED_REDUNDANCY, INTELLIGENT_TIERING, ...
MakePublic = true, // sets the public-read canned ACL
Metadata = { ["uploaded-by"] = "user-123" }
});
// From a stream
await using var stream = File.OpenRead("photo.webp");
Result<S3ObjectInfo> b = await s3.PutObjectAsync(
"my-bucket", "img/photo.webp", stream,
new UploadOptions { ContentType = "image/webp" });
if (b.IsSuccess)
Console.WriteLine(b.Value!.PublicUrl); // empty unless publicUrl is configured
Each successful upload publishes an ObjectUploadedEvent. When the connection has disablePayloadSigning enabled (R2), the request automatically opts out of streaming payload signing.
Downloading objects
Download as a byte[] for small payloads, or as a Stream to pipe large objects without buffering them in memory.
Result<byte[]> bytes = await s3.GetObjectAsync("my-bucket", "docs/report.pdf");
if (bytes.IsSuccess)
await File.WriteAllBytesAsync("report.pdf", bytes.Value!);
Result<Stream> streamResult = await s3.GetObjectStreamAsync("my-bucket", "video.mp4");
if (streamResult.IsSuccess)
{
await using var src = streamResult.Value!; // caller disposes the stream
await using var dst = File.Create("video.mp4");
await src.CopyToAsync(dst);
}
DownloadOptions adds byte-range reads and version retrieval:
// Read bytes 0–1023 (a 1 KiB range)
Result<byte[]> head = await s3.GetObjectAsync(
"my-bucket", "video.mp4",
new DownloadOptions { RangeStart = 0, RangeEnd = 1023 });
// Retrieve a specific version
Result<Stream> old = await s3.GetObjectStreamAsync(
"my-bucket", "config.json",
new DownloadOptions { VersionId = "abc123" });
GetObjectAsync is built on top of GetObjectStreamAsync — it reads the stream fully into a byte array for you.
Metadata & existence
Result<S3ObjectInfo> info = await s3.GetObjectInfoAsync("my-bucket", "docs/report.pdf");
if (info.IsSuccess)
{
var o = info.Value!;
Console.WriteLine($"{o.Size} bytes, {o.ContentType}, modified {o.LastModified:u}");
foreach (var (k, v) in o.Metadata)
Console.WriteLine($" {k} = {v}");
}
bool present = await s3.ObjectExistsAsync("my-bucket", "docs/report.pdf"); // plain bool
GetObjectInfoAsync performs a metadata-only HEAD request — it never downloads the object body.
Deleting objects
Result<bool> deleted = await s3.DeleteObjectAsync("my-bucket", "docs/report.pdf");
A successful delete publishes an ObjectDeletedEvent.
Listing with pagination & prefixes
ListObjectsAsync returns one page at a time. Filter by prefix, cap a page with maxKeys (default 1000), and walk pages with the continuation token.
string? token = null;
do
{
Result<ListObjectsResult> page = await s3.ListObjectsAsync(
"my-bucket", prefix: "uploads/", continuationToken: token, maxKeys: 500);
if (page.IsFailure) break;
foreach (var obj in page.Value!.Objects)
Console.WriteLine($"{obj.Key} ({obj.Size} bytes)");
token = page.Value.IsTruncated ? page.Value.NextContinuationToken : null;
}
while (token is not null);
CommonPrefixes surfaces virtual "folders" when the underlying request uses a delimiter, and Count is a convenience for Objects.Count.
Copying objects
Result<bool> copied = await s3.CopyObjectAsync(
sourceBucket: "my-bucket", sourceKey: "img/photo.webp",
destBucket: "backups", destKey: "2026/photo.webp");
Works within a single bucket or across buckets on the same connection.
Presigned URLs
Generate a temporary, signed GET URL so a client can fetch a private object directly without credentials.
Result<string> url = await s3.GeneratePresignedUrlAsync(
"my-bucket", "docs/report.pdf", TimeSpan.FromMinutes(15));
if (url.IsSuccess)
return Redirect(url.Value!); // valid for 15 minutes
The URL is signed for the GET verb against the connection's credentials and endpoint.
UploadOptions reference
public class UploadOptions
{
public string ContentType { get; set; } = ""; // MIME type
public Dictionary<string, string> Metadata { get; set; } = []; // user metadata
public string StorageClass { get; set; } = ""; // e.g. STANDARD, REDUCED_REDUNDANCY
public string CacheControl { get; set; } = ""; // Cache-Control header
public string ContentDisposition { get; set; } = ""; // Content-Disposition header
public bool MakePublic { get; set; } = false; // sets public-read ACL
public static UploadOptions Default(); // no overrides
}
Empty-string fields are omitted from the request. MakePublic sets the canned public-read ACL; the returned PublicUrl only fills in when publicUrl is configured on the connection.
DownloadOptions reference
public class DownloadOptions
{
public long? RangeStart { get; set; } // start byte offset (null = start of object)
public long? RangeEnd { get; set; } // inclusive end byte offset (null = end of object)
public string? VersionId { get; set; } // specific version (null = current)
public static DownloadOptions Default();
}
Setting either range bound issues a ranged request (RangeStart ?? 0 … RangeEnd ?? long.MaxValue).
Models
S3ObjectInfo
public class S3ObjectInfo
{
public string Key { get; set; }
public string BucketName { get; set; }
public long Size { get; set; }
public DateTime LastModified { get; set; }
public string ETag { get; set; }
public string StorageClass { get; set; }
public string ContentType { get; set; }
public Dictionary<string, string> Metadata { get; set; }
public string PublicUrl { get; set; } // "" unless a public URL is configured
}
ContentType and Metadata are populated for metadata and upload results; objects returned by ListObjectsAsync carry the fields available from a list response (key, size, last-modified, ETag, storage class).
BucketInfo
public class BucketInfo
{
public string Name { get; set; }
public DateTime CreationDate { get; set; }
public string Region { get; set; } // may be empty for non-AWS providers
}
ListObjectsResult
public class ListObjectsResult
{
public List<S3ObjectInfo> Objects { get; set; }
public string? NextContinuationToken { get; set; } // pass to the next call
public bool IsTruncated { get; set; } // more results remain
public List<string> CommonPrefixes { get; set; } // virtual folders
public int Count { get; } // Objects.Count
}
Configuration
The library writes config.storages3.json (section storages3) on first run. Keys are camelCase. One or more connections live under connections, each identified by a unique connectionId.
{
"enabled": true,
"connections": [
{
"connectionId": "Default",
"accessKey": "your-access-key",
"secretKey": "your-secret-key",
"serviceUrl": "https://s3.amazonaws.com",
"publicUrl": "https://cdn.example.com",
"region": "us-east-1",
"defaultBucket": "my-bucket",
"forcePathStyle": false,
"useHttps": true,
"timeoutSeconds": 30,
"maxRetries": 3,
"disablePayloadSigning": false
}
]
}
| Setting | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
true |
Master switch; when false no connections are registered and the health check reports disabled. |
connections |
array |
[] |
One or more connection blocks. At least one is required when enabled; connectionId values must be unique. |
connectionId |
string |
"Default" |
Identifier used to resolve the connection via GetService. |
accessKey |
string (secret) |
"" |
Access key ID. Required. |
secretKey |
string (secret) |
"" |
Secret access key. Required. |
serviceUrl |
string |
"" |
Endpoint URL. Required. See the provider table above. |
publicUrl |
string |
"" |
Base URL for building public object links; leave blank for private buckets. |
region |
string |
"us-east-1" |
Request-signing region. Use "auto" for Cloudflare R2. |
defaultBucket |
string |
"" |
Bucket used when code doesn't specify one. |
forcePathStyle |
bool |
true |
Path-style addressing (host/bucket/key). Required for MinIO and most non-AWS services; set false for AWS and R2. |
useHttps |
bool |
true |
Use HTTPS for the connection. |
timeoutSeconds |
int |
30 |
HTTP request timeout (1–600). |
maxRetries |
int |
3 |
Retry attempts on transient failures (0–20). |
disablePayloadSigning |
bool |
false |
Disables streaming AWS4 payload signing; set true for Cloudflare R2. |
Configuration is validated at initialization: a connection must supply accessKey, secretKey, and serviceUrl; timeoutSeconds must be positive; maxRetries must be non-negative; and connectionIds must not collide. Invalid configuration throws during startup.
Events
All three events implement IEvent and are published to the CodeLogic event bus. Each carries the originating ConnectionId.
| Event | Published when | Payload |
|---|---|---|
ObjectUploadedEvent |
PutObjectAsync succeeds |
ConnectionId, BucketName, Key, Size, UploadedAt |
ObjectDeletedEvent |
DeleteObjectAsync succeeds |
ConnectionId, BucketName, Key, DeletedAt |
BucketCreatedEvent |
CreateBucketAsync succeeds |
ConnectionId, BucketName, CreatedAt |
Health check
HealthCheckAsync() tests every registered connection by listing one object from its configured defaultBucket. This supports bucket-scoped credentials; connections without a default bucket fall back to listing buckets. Each check is bounded to ten seconds, then the library aggregates:
- Healthy — all connections respond (or the library is disabled).
- Degraded — some connections respond, some fail (the report names the failed IDs).
- Unhealthy — the library isn't initialized, has no connections, or every connection fails.
var status = await storage.HealthCheckAsync();
// status.Status : Healthy | Degraded | Unhealthy
For low-level access you can reach the underlying clients through storage.ConnectionManager (e.g. ConnectionManager.GetClient(id)), but most code should stay on S3StorageService.
See also
- Getting Started — load, configure, and use any
CL.*library. - API Reference — generated type/member documentation.
- Package on NuGet