Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
</PropertyGroup>
<ItemGroup>
<!-- AWS SDK -->
<PackageVersion Include="AWSSDK.Core" Version="4.0.3.6" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.9" />
<PackageVersion Include="AWSSDK.DynamoDBv2" Version="4.0.10.5" />
<PackageVersion Include="AWSSDK.EventBridge" Version="4.0.5.10" />
<PackageVersion Include="AWSSDK.Lambda" Version="4.0.12.1" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.24.3" />
<PackageVersion Include="AWSSDK.SecurityToken" Version="4.0.5.4" />
<PackageVersion Include="AWSSDK.SimpleNotificationService" Version="4.0.2.10" />
<PackageVersion Include="AWSSDK.SQS" Version="4.0.2.9" />
Expand Down
15 changes: 15 additions & 0 deletions Goa.sln
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goa.Clients.Sns.Benchmarks"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goa.Clients.EventBridge.Benchmarks", "tests\Clients\Goa.Clients.EventBridge.Benchmarks\Goa.Clients.EventBridge.Benchmarks.csproj", "{71D5E069-1555-4C11-B45C-399BC26FC258}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goa.Clients.S3.Tests", "tests\Clients\Goa.Clients.S3.Tests\Goa.Clients.S3.Tests.csproj", "{CC3715F8-340F-4F1E-BB46-1078CD764E99}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -691,6 +693,18 @@ Global
{71D5E069-1555-4C11-B45C-399BC26FC258}.Release|x64.Build.0 = Release|Any CPU
{71D5E069-1555-4C11-B45C-399BC26FC258}.Release|x86.ActiveCfg = Release|Any CPU
{71D5E069-1555-4C11-B45C-399BC26FC258}.Release|x86.Build.0 = Release|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Debug|x64.ActiveCfg = Debug|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Debug|x64.Build.0 = Debug|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Debug|x86.ActiveCfg = Debug|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Debug|x86.Build.0 = Debug|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Release|Any CPU.Build.0 = Release|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Release|x64.ActiveCfg = Release|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Release|x64.Build.0 = Release|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Release|x86.ActiveCfg = Release|Any CPU
{CC3715F8-340F-4F1E-BB46-1078CD764E99}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -747,5 +761,6 @@ Global
{22A37049-1E03-4D6A-817C-47B74BF6DF1B} = {B5DC1548-387B-4ABB-B4E8-CED73AFD8863}
{C3D8BB6D-BAA5-4458-B835-1E44F5826A43} = {B5DC1548-387B-4ABB-B4E8-CED73AFD8863}
{71D5E069-1555-4C11-B45C-399BC26FC258} = {B5DC1548-387B-4ABB-B4E8-CED73AFD8863}
{CC3715F8-340F-4F1E-BB46-1078CD764E99} = {B5DC1548-387B-4ABB-B4E8-CED73AFD8863}
EndGlobalSection
EndGlobal
24 changes: 15 additions & 9 deletions src/Clients/Goa.Clients.Core/Http/HttpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,31 @@ public static class HttpOptions
/// <summary>
/// HTTP request option key for specifying the AWS region.
/// </summary>
public static HttpRequestOptionsKey<string> Region = new(nameof(Region));
public static readonly HttpRequestOptionsKey<string> Region = new(nameof(Region));

/// <summary>
/// HTTP request option key for specifying the AWS service name.
/// </summary>
public static HttpRequestOptionsKey<string> Service = new(nameof(Service));
public static readonly HttpRequestOptionsKey<string> Service = new(nameof(Service));

/// <summary>
/// HTTP request option key for specifying the operation target.
/// </summary>
public static HttpRequestOptionsKey<string> Target = new(nameof(Target));
public static readonly HttpRequestOptionsKey<string> Target = new(nameof(Target));

/// <summary>
/// HTTP request option key for specifying the API version.
/// </summary>
public static HttpRequestOptionsKey<string> ApiVersion = new(nameof(ApiVersion));
public static readonly HttpRequestOptionsKey<string> ApiVersion = new(nameof(ApiVersion));

/// <summary>
/// HTTP request option key for specifying the request payload as UTF-8 bytes.
/// </summary>
public static HttpRequestOptionsKey<ReadOnlyMemory<byte>> Payload = new(nameof(Payload));
public static readonly HttpRequestOptionsKey<ReadOnlyMemory<byte>> Payload = new(nameof(Payload));

/// <summary>
/// HTTP request option key indicating that the request path is already URI-encoded and must be
/// used as-is as the SigV4 canonical URI (S3-style single encoding) instead of being re-encoded.
/// </summary>
public static readonly HttpRequestOptionsKey<bool> UseSingleUriEncoding = new(nameof(UseSingleUriEncoding));
}
43 changes: 37 additions & 6 deletions src/Clients/Goa.Clients.Core/Http/RequestSigner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,16 @@ private async ValueTask<string> SignRequestWithCredentialsAsync(
time.TryFormat(shortDate, out _, "yyyyMMdd".AsSpan());
time.TryFormat(longDate, out _, "yyyyMMdd'T'HHmmss'Z'".AsSpan());

// Add required headers to the request for immediate usage
if (request.RequestUri != null)
request.Headers.Host = request.RequestUri.Host;
// Add required headers to the request for immediate usage.
// Match the canonical host written by WriteHost: include a non-default port so the Host
// header sent on the wire agrees with the signed canonical host (otherwise SigV4 validation
// fails against endpoints on non-default ports, e.g. LocalStack's mapped port).
if (request.RequestUri is { } uri)
{
// Mirror WriteHost exactly: use IdnHost and append a non-default port.
var hostName = uri.IdnHost ?? uri.Host;
request.Headers.Host = uri.IsDefaultPort ? hostName : $"{hostName}:{uri.Port}";
}
request.Headers.TryAddWithoutValidation(RequestHeaders.AmzDate, new string(longDate));

Span<char> payloadHex = stackalloc char[64];
Expand Down Expand Up @@ -306,9 +313,18 @@ private async ValueTask<string> SignRequestWithCredentialsAsync(
else Array.Sort(headerRefs, 0, totalHeaders, HeaderRefComparer.OrdinalIgnoreCase);

var pathStr = request.RequestUri?.AbsolutePath ?? RootPath;
var canonUriLen = MeasureCanonicalUri(pathStr);

// Process query parameters with efficient encoding
// S3-style signing uses the already-encoded request path as-is for the canonical URI,
// whereas other services require the path to be URI-encoded a second time.
var useSingleUriEncoding = request.Options.TryGetValue(HttpOptions.UseSingleUriEncoding, out var singleEncode) && singleEncode;
var canonUriLen = useSingleUriEncoding
? Math.Max(pathStr.Length, 1)
: MeasureCanonicalUri(pathStr);

// Process query parameters with efficient encoding.
// NOTE: query values are RFC 3986 encoded once here. If/when an S3 list operation
// (e.g. ListObjectsV2) is added, ensure its query string is built pre-encoded so the
// signed canonical query matches the value sent on the wire.
var rawQuery = request.RequestUri?.Query;
QueryPart[]? qparts = null;
char[]? qEncodedBuf = null;
Expand Down Expand Up @@ -363,7 +379,22 @@ private async ValueTask<string> SignRequestWithCredentialsAsync(
var m = request.Method.Method ?? "GET";
m.AsSpan().CopyTo(canonical.Slice(pos)); pos += m.Length; canonical[pos++] = '\n';

pos += WriteCanonicalUri(canonical.Slice(pos), pathStr);
if (useSingleUriEncoding)
{
if (pathStr.Length == 0)
{
canonical[pos++] = '/';
}
else
{
pathStr.AsSpan().CopyTo(canonical.Slice(pos));
pos += pathStr.Length;
}
}
else
{
pos += WriteCanonicalUri(canonical.Slice(pos), pathStr);
}
canonical[pos++] = '\n';

if (qparts is null || qEncodedBuf is null)
Expand Down
7 changes: 0 additions & 7 deletions src/Clients/Goa.Clients.S3/Class1.cs

This file was deleted.

62 changes: 62 additions & 0 deletions src/Clients/Goa.Clients.S3/Errors/S3ErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace Goa.Clients.S3.Errors;

/// <summary>
/// Common error codes returned by S3 operations.
/// </summary>
public static class S3ErrorCodes
{
/// <summary>
/// The specified key does not exist.
/// </summary>
public const string NoSuchKey = "NoSuchKey";

/// <summary>
/// The specified bucket does not exist.
/// </summary>
public const string NoSuchBucket = "NoSuchBucket";

/// <summary>
/// Access to the resource is denied.
/// </summary>
public const string AccessDenied = "AccessDenied";

/// <summary>
/// The requested range is not satisfiable.
/// </summary>
public const string InvalidRange = "InvalidRange";

/// <summary>
/// The proposed upload exceeds the maximum allowed object size.
/// </summary>
public const string EntityTooLarge = "EntityTooLarge";

/// <summary>
/// At least one of the preconditions specified did not hold.
/// </summary>
public const string PreconditionFailed = "PreconditionFailed";

/// <summary>
/// The operation is not valid for the current state of the object.
/// </summary>
public const string InvalidObjectState = "InvalidObjectState";

/// <summary>
/// Reduce your request rate.
/// </summary>
public const string SlowDown = "SlowDown";

/// <summary>
/// The supplied object key is not valid (empty, contains dot-segments, or control characters).
/// </summary>
public const string InvalidKey = "S3.InvalidKey";

/// <summary>
/// The supplied bucket name does not satisfy S3 bucket naming rules.
/// </summary>
public const string InvalidBucketName = "S3.InvalidBucketName";

/// <summary>
/// A user-defined metadata name is not a valid HTTP token.
/// </summary>
public const string InvalidMetadata = "S3.InvalidMetadata";
}
8 changes: 8 additions & 0 deletions src/Clients/Goa.Clients.S3/Goa.Clients.S3.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,12 @@
<Description>S3 client for object storage operations in high-performance AWS Lambda functions</Description>
<PackageTags>$(PackageBaseTags);s3;client;storage;objects</PackageTags>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="ErrorOr" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Goa.Clients.S3.Tests" />
</ItemGroup>
</Project>
51 changes: 51 additions & 0 deletions src/Clients/Goa.Clients.S3/IS3Client.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using ErrorOr;
using Goa.Clients.S3.Operations.DeleteObject;
using Goa.Clients.S3.Operations.GetObject;
using Goa.Clients.S3.Operations.HeadObject;
using Goa.Clients.S3.Operations.PutObject;

namespace Goa.Clients.S3;

/// <summary>
/// High-performance S3 client interface optimized for AWS Lambda usage.
/// All operations use strongly-typed request objects and return ErrorOr results.
/// </summary>
public interface IS3Client
{
/// <summary>
/// Uploads an object to the specified bucket.
/// </summary>
/// <param name="request">The put object request.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The put object response, or an error if the operation failed.</returns>
Task<ErrorOr<PutObjectResponse>> PutObjectAsync(PutObjectRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves an object from the specified bucket.
/// </summary>
/// <remarks>
/// The entire object body is buffered into memory before the response is returned. For large
/// objects, set <see cref="GetObjectRequest.Range"/> to fetch the object in bounded slices and
/// avoid excessive memory pressure.
/// </remarks>
/// <param name="request">The get object request.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The get object response, or an error if the operation failed.</returns>
Task<ErrorOr<GetObjectResponse>> GetObjectAsync(GetObjectRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves the metadata of an object without returning the object body.
/// </summary>
/// <param name="request">The head object request.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The head object response, or an error if the operation failed.</returns>
Task<ErrorOr<HeadObjectResponse>> HeadObjectAsync(HeadObjectRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes an object from the specified bucket. Deleting a key that does not exist succeeds.
/// </summary>
/// <param name="request">The delete object request.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The delete object response, or an error if the operation failed.</returns>
Task<ErrorOr<DeleteObjectResponse>> DeleteObjectAsync(DeleteObjectRequest request, CancellationToken cancellationToken = default);
}
18 changes: 18 additions & 0 deletions src/Clients/Goa.Clients.S3/Log.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Logging;

namespace Goa.Clients.S3;

internal static partial class Log
{
[LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed to put object {Key} to S3 bucket {Bucket}")]
public static partial void PutObjectFailed(this ILogger logger, Exception exception, string bucket, string key);

[LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Failed to get object {Key} from S3 bucket {Bucket}")]
public static partial void GetObjectFailed(this ILogger logger, Exception exception, string bucket, string key);

[LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Failed to head object {Key} in S3 bucket {Bucket}")]
public static partial void HeadObjectFailed(this ILogger logger, Exception exception, string bucket, string key);

[LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Failed to delete object {Key} from S3 bucket {Bucket}")]
public static partial void DeleteObjectFailed(this ILogger logger, Exception exception, string bucket, string key);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Goa.Clients.S3.Operations.DeleteObject;

/// <summary>
/// Request for the DeleteObject operation.
/// </summary>
public sealed class DeleteObjectRequest
{
/// <summary>
/// The name of the bucket containing the object.
/// </summary>
public required string Bucket { get; set; }

/// <summary>
/// The key of the object to delete.
/// </summary>
public required string Key { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Goa.Clients.S3.Operations.DeleteObject;

/// <summary>
/// Response from the DeleteObject operation.
/// </summary>
public sealed class DeleteObjectResponse
{
// DeleteObject returns an empty response on success. S3 responds with 204 No Content
// even when the specified key does not exist on an unversioned bucket.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Goa.Clients.S3.Operations.GetObject;

/// <summary>
/// Request for the GetObject operation.
/// </summary>
public sealed class GetObjectRequest
{
/// <summary>
/// The name of the bucket containing the object.
/// </summary>
public required string Bucket { get; set; }

/// <summary>
/// The key of the object to retrieve.
/// </summary>
public required string Key { get; set; }

/// <summary>
/// The byte range of the object to retrieve (e.g. "bytes=0-63").
/// </summary>
public string? Range { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Goa.Clients.S3.Operations.GetObject;

/// <summary>
/// Response from the GetObject operation.
/// </summary>
public sealed class GetObjectResponse
{
/// <summary>
/// The object content.
/// </summary>
public byte[] Body { get; set; } = [];

/// <summary>
/// The MIME content type of the object.
/// </summary>
public string? ContentType { get; set; }

/// <summary>
/// The size of the returned content in bytes.
/// </summary>
public long ContentLength { get; set; }

/// <summary>
/// The entity tag of the object, as returned by S3 (including surrounding quotes).
/// </summary>
public string? ETag { get; set; }

/// <summary>
/// The date and time the object was last modified.
/// </summary>
public DateTimeOffset? LastModified { get; set; }

/// <summary>
/// User-defined metadata stored with the object ("x-amz-meta-*" headers, prefix stripped), if any.
/// </summary>
public Dictionary<string, string>? Metadata { get; set; }
}
Loading
Loading