🇬🇧 English | 🇺🇦 Українська
6. C++ API¶
The same functionality as in Blueprint, with no intermediate layer.
Adding the module¶
// YourModule.Build.cs
PrivateDependencyModuleNames.Add("S3CompatibleStorageDemo");
Headers you'll need:
#include "DemoS3Subsystem.h" // get a ready-made client
#include "DemoS3Client.h" // operations
#include "DemoS3Types.h" // result and configuration structs
There's deliberately no single "umbrella" header: explicit includes don't add to compile time for everyone who uses the plugin.
Getting a client¶
UDemoS3Client* Client = GetGameInstance()->GetSubsystem<UDemoS3Subsystem>()->GetDefaultClient();
The subsystem takes its configuration from the project settings and keeps the client alive.
If the configuration is assembled in code:
FDemoS3Config Config;
Config.Endpoint.Provider = EDemoS3Provider::CloudflareR2;
Config.Endpoint.EndpointURL = TEXT("https://<account>.r2.cloudflarestorage.com");
Config.Endpoint.Region = TEXT("auto");
Config.Normalize();
UDemoS3Client* Client = UDemoS3Client::CreateClient(Config);
Lifetime.
UDemoS3Clientis aUObject, so something has to reference it: aUPROPERTYor aTStrongObjectPtr. The client from the subsystem already takes care of this.Operations already running keep themselves alive on their own, so garbage-collecting the client mid-transfer breaks nothing — the transfer simply runs to completion.
Uploading¶
FDemoS3TransferHandleRef Transfer = Client->UploadFile(
TEXT("my-bucket"),
TEXT("saves/player.sav"),
LocalPath,
TEXT("application/octet-stream"),
{}, // metadata
FDemoS3OnUploadResult::CreateLambda(
[](const FDemoS3UploadResult& Result)
{
if (Result.IsSuccess())
{
UE_LOG(LogTemp, Log, TEXT("Uploaded, ETag %s, %lld bytes%s"),
*Result.ETag, Result.BytesUploaded,
Result.bWasMultipart ? TEXT(", multipart") : TEXT(""));
}
else
{
// ToLogString carries status, code, message and the hint.
UE_LOG(LogTemp, Error, TEXT("%s"), *Result.ToLogString());
}
}),
FDemoS3OnProgress::CreateLambda(
[](const FDemoS3TransferProgress& Progress)
{
if (Progress.bTotalKnown)
{
UE_LOG(LogTemp, Verbose, TEXT("%.1f%%"), Progress.Percentage);
}
}));
From memory — UploadBytes with the same parameters, but an array instead of a path.
About LocalPath. Both an absolute path ("C:/Users/You/save.png",
"/Users/You/save.png") and one relative to the project root
("Saved/SaveGames/player.sav") are accepted — the second expands through
FPaths::ConvertRelativePathToFull(FPaths::ProjectDir(), LocalPath), so the same string leads
to the same place in the editor and in a packaged game, on any platform. DownloadFile and
DownloadFileChunked accept a destination path the same way. This is exactly the mechanism
behind the Local File Path node in Blueprint — see
«File Path» for a full breakdown
with examples for each platform.
Downloading¶
// To a file: streamed to disk, memory doesn't depend on the size.
Client->DownloadFile(TEXT("my-bucket"), TEXT("patches/1.2.pak"), LocalPath,
FDemoS3OnDownloadResult::CreateLambda(
[](const FDemoS3OperationResult& Result, const TArray<uint8>& Data)
{
// Data is empty: the bytes are in the file.
}));
// To memory.
Client->DownloadBytes(TEXT("my-bucket"), TEXT("config.json"),
FDemoS3OnDownloadResult::CreateLambda(
[](const FDemoS3OperationResult& Result, const TArray<uint8>& Data)
{
if (Result.IsSuccess())
{
FString Json;
FFileHelper::BufferToString(Json, Data.GetData(), Data.Num());
}
}));
// Part of an object.
Client->DownloadRange(TEXT("my-bucket"), TEXT("video.mp4"), 0, 1023,
FDemoS3OnRangeResult::CreateLambda(
[](const FDemoS3RangeDownloadResult& Result)
{
// Result.TotalObjectSize - the full size, even though we only asked for a kilobyte.
}));
// As a series of range requests rather than one stream - and therefore the only download that
// can resume from where it broke off: whatever arrived stays in <file>.s3part.
// 0 takes the chunk size from the transport settings; it can differ between attempts.
Client->DownloadFileChunked(TEXT("my-bucket"), TEXT("patches/1.2.pak"), LocalPath,
/*ChunkSizeBytes=*/0,
FDemoS3OnDownloadResult::CreateLambda(
[](const FDemoS3OperationResult& Result, const TArray<uint8>& Data)
{
// EDemoS3Result::PreconditionFailed - the object was rewritten mid-download, so the
// plugin refused to stitch two versions together. Read it again.
}));
Listing and pagination¶
void ListPage(UDemoS3Client* Client, const FString& Token)
{
Client->ListObjects(TEXT("my-bucket"), TEXT("saves/"), TEXT("/"), 1000, Token,
FDemoS3OnListObjectsResult::CreateLambda(
[Client](const FDemoS3ListObjectsResult& Result)
{
if (!Result.IsSuccess())
{
UE_LOG(LogTemp, Error, TEXT("%s"), *Result.ToLogString());
return;
}
for (const FString& Folder : Result.CommonPrefixes) { /* "folders" */ }
for (const FDemoS3Object& Object : Result.Objects) { /* files */ }
if (Result.bIsTruncated)
{
ListPage(Client, Result.NextContinuationToken);
}
}));
}
// Lists the account's buckets. Amazon only, in its global form: R2 answers this call with 403.
Client->ListBuckets(
FDemoS3OnListBucketsResult::CreateLambda(
[](const FDemoS3ListBucketsResult& Result)
{
for (const FDemoS3Bucket& Bucket : Result.Buckets) { /* Bucket.Name, Bucket.CreationDate */ }
}));
Metadata and deletion¶
Client->GetMetadata(TEXT("my-bucket"), TEXT("save.sav"),
FDemoS3OnMetadataResult::CreateLambda(
[](const FDemoS3MetadataResult& Result)
{
// Result.ContentLength, ContentType, ETag, LastModified
// Result.Metadata - custom metadata, keys lowercased
}));
// Replacing metadata: the object is copied onto itself on the provider's side.
TMap<FString, FString> NewMetadata;
NewMetadata.Add(TEXT("game-version"), TEXT("1.4.0"));
Client->SetMetadata(TEXT("my-bucket"), TEXT("save.sav"), NewMetadata, TEXT("application/octet-stream"),
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
// Batch deletion: up to a thousand keys per request.
Client->DeleteObjects(TEXT("my-bucket"), MoveTemp(Keys),
FDemoS3OnBatchDeleteResult::CreateLambda(
[](const FDemoS3BatchDeleteResult& Result)
{
// Can be PartialSuccess: the request went through, some keys were rejected.
for (const FDemoS3DeletedObject& Item : Result.Results)
{
if (!Item.bDeleted)
{
UE_LOG(LogTemp, Warning, TEXT("%s: %s"), *Item.Key, *Item.ErrorMessage);
}
}
}));
Copying an object¶
Copies an object entirely on the provider's side — bytes never pass through the client. Source and destination can be in different buckets.
Client->CopyObject(
TEXT("my-bucket"), TEXT("uploads/screenshot.png"), // source
TEXT("my-bucket"), TEXT("archive/2026/screenshot.png"), // destination
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
SetMetadata above uses this same provider mechanism — copying the object onto itself — to
replace metadata without resending its contents.
Object tags¶
Key-value pairs next to an object which, unlike metadata, can be changed at any time without rewriting the object and without changing its last-modified time. Lifecycle rules and access policies can select objects by tag specifically — they can't see metadata. The difference between tags and metadata is covered in detail in «Tags or Metadata: Which One to Use».
Client->GetObjectTags(TEXT("my-bucket"), TEXT("uploads/screenshot.png"),
FDemoS3OnTagsResult::CreateLambda(
[](const FDemoS3TagsResult& Result)
{
// Result.Tags - an empty map on success just means "there are simply no tags."
}));
// This replaces rather than merges: anything missing from the passed map disappears.
TMap<FString, FString> Tags;
Tags.Add(TEXT("moderation"), TEXT("pending"));
Client->SetObjectTags(TEXT("my-bucket"), TEXT("uploads/screenshot.png"), Tags,
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
Client->DeleteObjectTags(TEXT("my-bucket"), TEXT("uploads/screenshot.png"),
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
Values can contain ampersands, quotes or Cyrillic without any extra escaping on your part — the plugin handles the request and response XML documents itself.
Buckets and lifecycle rules¶
// Only needed on providers that don't create a bucket on the first write to it.
Client->CreateBucket(TEXT("my-game-saves"),
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
// The provider refuses as long as the bucket isn't empty (ErrorCode == "BucketNotEmpty") - the
// plugin deliberately doesn't empty the bucket itself; that's a separate, irreversible action.
Client->DeleteBucket(TEXT("my-game-saves"),
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
A lifecycle rule is an instruction the bucket carries out on its own, on the provider's own schedule (typically once a day), with no call from the game or the server at all. The most important practical use is cleaning up parts from interrupted multipart uploads, which the plugin deliberately leaves in the bucket so they can be resumed (see 5. Transfers and the FAQ on resuming uploads):
FDemoS3LifecycleRule SweepIncompleteUploads;
SweepIncompleteUploads.Id = TEXT("sweep-uploads");
SweepIncompleteUploads.AbortIncompleteUploadsAfterDays = 7;
Client->SetBucketLifecycle(TEXT("my-game-saves"), { SweepIncompleteUploads },
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
SetBucketLifecycle replaces the bucket's entire rule set — to add one to the existing
ones, read them first through GetBucketLifecycle. An empty array isn't meant to mean "change
nothing": to remove every rule on purpose, call DeleteBucketLifecycle separately.
Client->GetBucketLifecycle(TEXT("my-game-saves"),
FDemoS3OnLifecycleResult::CreateLambda(
[](const FDemoS3LifecycleResult& Result)
{
// Result.Rules - an empty array on success means "no rules," not an error.
}));
Client->DeleteBucketLifecycle(TEXT("my-game-saves"),
FDemoS3OnResult::CreateLambda([](const FDemoS3OperationResult& Result) {}));
A full description of FDemoS3LifecycleRule's fields (Prefix, TagFilters, ExpireAfterDays) is
in «Lifecycle Rules».
Cancellation¶
FDemoS3TransferHandleRef Transfer = Client->DownloadFile(...);
// Later:
Transfer->Cancel();
// Or everything at once:
Client->CancelAllTransfers();
The handle also gives you IsFinished(), GetState() and GetProgress(). Holding onto it
after completion is harmless.
Presigned URLs¶
FDemoS3PresignedUrlResult Url = Client->GeneratePresignedUrl(
TEXT("my-bucket"), TEXT("uploads/screenshot.png"),
EDemoS3HttpMethod::PUT, 900);
if (Url.IsSuccess())
{
// Hand it to the client; it will upload the file with no keys of its own at all.
}
Computed locally, with no request to the provider.
Credentials on a specific client¶
The shortest way to hand a client keys your backend just returned:
Client->SetStaticCredentials(
Response.AccessKeyId,
Response.SecretAccessKey,
Response.SessionToken,
/*ExpiresInSeconds=*/3600); // 0 - keys with no expiration
Applies to whichever client it's called on — including a profile's client from
Get S3 Client For Profile. That's what sets it apart from UDemoS3Subsystem::SetRuntimeCredentials,
which only configures the default client. Also available from Blueprint, under the same name.
The keys are kept only in memory; the plugin stops using them a little before the stated lifetime, so a request signed right at the end doesn't arrive after it has already expired.
A custom credentials provider¶
When keys need to refresh themselves rather than being set once — for a game client receiving short-lived keys from your backend:
#include "Auth/DemoS3CredentialsProviders.h"
auto Provider = MakeShared<FDemoS3CredentialsProvider_Callback, ESPMode::ThreadSafe>(
FDemoS3CredentialsProvider_Callback::FDemoS3CredentialsFetch::CreateLambda(
[](FDemoS3CredentialsResolved OnFetched)
{
MyBackend::RequestS3Credentials(
[OnFetched](bool bOk, const FStsResponse& Response)
{
OnFetched.ExecuteIfBound(bOk, FDemoS3Credentials(
Response.AccessKeyId,
Response.SecretAccessKey,
Response.SessionToken,
Response.ExpiresAtUtc));
});
}));
Client->SetCredentialsProvider(Provider);
A provider refreshes keys before they expire and coalesces concurrent requests: twenty transfers on expired keys at once give your backend a single request.
Ready-made implementations: _Static, _Environment, _LocalUserStore, _Callback,
_Anonymous, and FDemoS3CredentialsProviderChain, which tries them in order.
Swapping the transport for tests¶
Client->SetHttpTransport(MyFakeTransport);
This exact seam is what lets you test retries, multipart upload sequencing and cancellation without a network. Details in Testing.