diff --git a/README.md b/README.md index 444b5cf..749d6ed 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ output, err := client.CreateSnapshotEnabledBucket(ctx, &s3.CreateBucketInput{ output, err := client.CreateBucketSnapshot(ctx, "Initial backup", &s3.CreateBucketInput{ Bucket: aws.String("my-bucket"), }) +// output.SnapshotVersion holds the version returned by Tigris. ``` #### Fork a Bucket diff --git a/bundle.go b/bundle.go index e22528c..b588d89 100644 --- a/bundle.go +++ b/bundle.go @@ -39,7 +39,7 @@ var bundleHTTPClient = &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext, - TLSHandshakeTimeout: 10 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 60 * time.Second, }, } diff --git a/client.go b/client.go index d917555..827936e 100644 --- a/client.go +++ b/client.go @@ -16,6 +16,11 @@ type Client struct { *s3.Client } +// S3 returns the underlying S3 client. +func (c *Client) S3() *s3.Client { + return c.Client +} + // CreateBucketFork creates a fork of the source bucket named target. // // If you want to specify an exact snapshot version to fork from, use tigrisheaders.WithSnapshotVersion. @@ -27,11 +32,32 @@ func (c *Client) CreateBucketFork(ctx context.Context, source, target string, op }, opts...) } +// CreateBucketSnapshotOutput is the response from CreateBucketSnapshot. +// It wraps the underlying s3.CreateBucketOutput and surfaces the snapshot +// version that Tigris returns in the X-Tigris-Snapshot-Version response header. +type CreateBucketSnapshotOutput struct { + *s3.CreateBucketOutput + + // SnapshotVersion is the version identifier of the snapshot just created. + // Empty if the server did not return a version header. + SnapshotVersion string +} + // CreateBucketSnapshot creates a snapshot with the given description for a bucket. -func (c *Client) CreateBucketSnapshot(ctx context.Context, description string, in *s3.CreateBucketInput, opts ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { +// The returned output carries the snapshot version in SnapshotVersion. +func (c *Client) CreateBucketSnapshot(ctx context.Context, description string, in *s3.CreateBucketInput, opts ...func(*s3.Options)) (*CreateBucketSnapshotOutput, error) { opts = append(opts, tigrisheaders.WithTakeSnapshot(description)) - return c.Client.CreateBucket(ctx, in, opts...) + resp, err := c.Client.CreateBucket(ctx, in, opts...) + if err != nil { + return nil, err + } + + out := &CreateBucketSnapshotOutput{CreateBucketOutput: resp} + if rawResp, ok := middleware.GetRawResponse(resp.ResultMetadata).(*http.Response); ok { + out.SnapshotVersion = rawResp.Header.Get("X-Tigris-Snapshot-Version") + } + return out, nil } // CreateSnapshotEnabledBucket creates a new bucket with the ability to take snapshots and fork the contents of it. diff --git a/go.mod b/go.mod index 0b893b9..db5e587 100644 --- a/go.mod +++ b/go.mod @@ -3,30 +3,30 @@ module github.com/tigrisdata/storage-go go 1.25.5 require ( - github.com/aws/aws-sdk-go-v2 v1.41.0 - github.com/aws/aws-sdk-go-v2/config v1.32.6 - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 - github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 - github.com/aws/smithy-go v1.24.0 + github.com/aws/aws-sdk-go-v2 v1.41.6 + github.com/aws/aws-sdk-go-v2/config v1.32.16 + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.17 + github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 + github.com/aws/smithy-go v1.25.0 github.com/joho/godotenv v1.5.1 ) require ( github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index f761366..13f2383 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,43 @@ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= -github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= +github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= +github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.17 h1:95y7/EqethAhFwMKJ9cDutzBhsS1h8uBwkJ5rp8pNTU= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.17/go.mod h1:77baheqr62SkTw77HWH8qpdWTd2gXKN0xg0qLvDSkpk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/simplestorage/bucket_options.go b/simplestorage/bucket_options.go index 9da9426..0bd6ca5 100644 --- a/simplestorage/bucket_options.go +++ b/simplestorage/bucket_options.go @@ -2,6 +2,7 @@ package simplestorage import ( "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/tigrisdata/storage-go/tigrisheaders" ) @@ -16,12 +17,24 @@ type BucketOptions struct { // SnapshotVersion specifies a snapshot version to target (for forking from specific snapshot). SnapshotVersion string + // SourceBucketSnapshot specifies the snapshot version to fork from. + SourceBucketSnapshot string + // Region sets static replication region for the bucket. // This field is stored for visibility but the actual behavior is configured // via S3Options (see WithBucketRegion). Keeping the field enables debugging // and potential future use in bucket info responses. Region string + // DefaultTier sets the storage class tier for the bucket. + DefaultTier string + + // Consistency sets the consistency level for the bucket ("strict" or "default"). + Consistency string + + // Access sets the bucket-level access type (public or private). + Access AccessType + // MaxKeys sets the maximum number of results to return in ListBuckets. MaxKeys *int32 @@ -36,6 +49,7 @@ type BucketOptions struct { func (BucketOptions) defaults() BucketOptions { return BucketOptions{ EnableSnapshot: false, + Access: AccessPrivate, MaxKeys: nil, ContinuationToken: nil, S3Options: []func(*s3.Options){}, @@ -84,3 +98,50 @@ func WithListToken(token string) BucketOption { o.ContinuationToken = &token } } + +// WithDefaultTier sets the storage class tier for the bucket. +// Valid values: "STANDARD", "STANDARD_IA", "GLACIER", "GLACIER_IR" +func WithDefaultTier(tier string) BucketOption { + return func(o *BucketOptions) { + o.DefaultTier = tier + o.S3Options = append(o.S3Options, tigrisheaders.WithStorageClass(tier)) + } +} + +// WithConsistentRead enables consistent read mode for the bucket. +func WithConsistentRead() BucketOption { + return func(o *BucketOptions) { + o.Consistency = "strict" + o.S3Options = append(o.S3Options, tigrisheaders.WithConsistentRead()) + } +} + +// WithForkSourceSnapshot specifies the snapshot version when forking from a bucket. +// Use this with CreateBucket when forking from a specific snapshot version. +func WithForkSourceSnapshot(snapshot string) BucketOption { + return func(o *BucketOptions) { + o.SourceBucketSnapshot = snapshot + o.S3Options = append(o.S3Options, tigrisheaders.WithForkSourceBucketSnapshot(snapshot)) + } +} + +// WithBucketAccess sets the bucket-level canned ACL (public or private). +// Use AccessPublic to allow anonymous reads via public-read, or AccessPrivate +// (the default) to require authenticated access. +func WithBucketAccess(access AccessType) BucketOption { + return func(o *BucketOptions) { + o.Access = access + } +} + +// bucketACL maps an AccessType to an S3 canned ACL for bucket operations. +// Defaults to private for unset values so callers don't accidentally inherit +// a permissive account-wide default. +func bucketACL(a AccessType) s3types.BucketCannedACL { + switch a { + case AccessPublic: + return s3types.BucketCannedACLPublicRead + default: + return s3types.BucketCannedACLPrivate + } +} diff --git a/simplestorage/buckets.go b/simplestorage/buckets.go index 832ef5d..6a46c30 100644 --- a/simplestorage/buckets.go +++ b/simplestorage/buckets.go @@ -75,14 +75,15 @@ func (c *Client) CreateBucket(ctx context.Context, bucket string, opts ...Bucket // Use CreateBucket if no snapshot options, otherwise use Tigris-specific method var err error + input := &s3.CreateBucketInput{ + Bucket: aws.String(bucket), + ACL: bucketACL(o.Access), + } + if o.EnableSnapshot { - _, err = c.cli.CreateSnapshotEnabledBucket(ctx, &s3.CreateBucketInput{ - Bucket: aws.String(bucket), - }, o.S3Options...) + _, err = c.cli.CreateSnapshotEnabledBucket(ctx, input, o.S3Options...) } else { - _, err = c.cli.CreateBucket(ctx, &s3.CreateBucketInput{ - Bucket: aws.String(bucket), - }, o.S3Options...) + _, err = c.cli.CreateBucket(ctx, input, o.S3Options...) } if err != nil { @@ -222,8 +223,7 @@ func (c *Client) CreateBucketSnapshot(ctx context.Context, bucket, description s doer(&o) } - // CreateBucketSnapshot uses CreateBucket with snapshot header - _, err := c.cli.CreateBucketSnapshot(ctx, description, &s3.CreateBucketInput{ + resp, err := c.cli.CreateBucketSnapshot(ctx, description, &s3.CreateBucketInput{ Bucket: aws.String(bucket), }, o.S3Options...) @@ -231,17 +231,20 @@ func (c *Client) CreateBucketSnapshot(ctx context.Context, bucket, description s return nil, fmt.Errorf("simplestorage: can't create snapshot for bucket %s: %w", bucket, err) } - // Note: The snapshot version is returned in HTTP headers that are not directly - // accessible through the AWS SDK response. Users can list snapshots to get the version. return &SnapshotInfo{ Name: description, - Version: "", + Version: resp.SnapshotVersion, Created: time.Now(), Bucket: bucket, }, nil } // ListBucketSnapshots lists all snapshots for the given bucket. +// +// Tigris returns each snapshot as a pseudo-bucket entry whose Name is the +// snapshot version identifier. The user-provided description is not returned +// by the ListBuckets API, so SnapshotInfo.Name is left empty; use the version +// from CreateBucketSnapshot's response if you need to correlate descriptions. func (c *Client) ListBucketSnapshots(ctx context.Context, bucket string, opts ...BucketOption) (*SnapshotList, error) { if bucket == "" { return nil, ErrBucketNameRequired @@ -252,35 +255,22 @@ func (c *Client) ListBucketSnapshots(ctx context.Context, bucket string, opts .. doer(&o) } - // Use the new tigrisheaders helper - o.S3Options = append(o.S3Options, tigrisheaders.WithListSnapshots(bucket)) - - resp, err := c.cli.ListBuckets(ctx, &s3.ListBucketsInput{}, o.S3Options...) - + resp, err := c.cli.ListBucketSnapshots(ctx, bucket, o.S3Options...) if err != nil { return nil, fmt.Errorf("simplestorage: can't list snapshots for bucket %s: %w", bucket, err) } result := &SnapshotList{ Bucket: bucket, - Snapshots: make([]SnapshotInfo, 0), + Snapshots: make([]SnapshotInfo, 0, len(resp.Buckets)), } - // Parse snapshot info from response buckets for _, b := range resp.Buckets { - // Extract snapshot info from bucket metadata. - // NOTE: The ListBuckets response does not expose a separate snapshot version field - // in the bucket structure, so Version is set to the bucket name. The actual - // snapshot version ID is returned in HTTP headers that are not directly - // accessible through the AWS SDK response. Use CreateBucketSnapshot for - // snapshot creation where the version is returned separately. - snap := SnapshotInfo{ - Name: lower(b.Name, ""), + result.Snapshots = append(result.Snapshots, SnapshotInfo{ Version: lower(b.Name, ""), Created: lower(b.CreationDate, time.Time{}), Bucket: bucket, - } - result.Snapshots = append(result.Snapshots, snap) + }) } return result, nil diff --git a/simplestorage/buckets_test.go b/simplestorage/buckets_test.go index db05979..276118b 100644 --- a/simplestorage/buckets_test.go +++ b/simplestorage/buckets_test.go @@ -293,6 +293,33 @@ func TestBucketOptions(t *testing.T) { } }, }, + { + name: "WithDefaultTier sets DefaultTier", + option: WithDefaultTier("STANDARD_IA"), + verify: func(t *testing.T, o *BucketOptions) { + if o.DefaultTier != "STANDARD_IA" { + t.Errorf("WithDefaultTier() set DefaultTier = %v, want %v", o.DefaultTier, "STANDARD_IA") + } + }, + }, + { + name: "WithConsistentRead sets Consistency", + option: WithConsistentRead(), + verify: func(t *testing.T, o *BucketOptions) { + if o.Consistency != "strict" { + t.Errorf("WithConsistentRead() set Consistency = %v, want %v", o.Consistency, "strict") + } + }, + }, + { + name: "WithForkSourceSnapshot sets SourceBucketSnapshot", + option: WithForkSourceSnapshot("snapshot-123"), + verify: func(t *testing.T, o *BucketOptions) { + if o.SourceBucketSnapshot != "snapshot-123" { + t.Errorf("WithForkSourceSnapshot() set SourceBucketSnapshot = %v, want %v", o.SourceBucketSnapshot, "snapshot-123") + } + }, + }, } for _, tt := range tests { diff --git a/simplestorage/client.go b/simplestorage/client.go index ccf81c4..1f6e498 100644 --- a/simplestorage/client.go +++ b/simplestorage/client.go @@ -2,6 +2,8 @@ package simplestorage import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" @@ -9,8 +11,22 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" + tmtypes "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager/types" "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" storage "github.com/tigrisdata/storage-go" + "github.com/tigrisdata/storage-go/tigrisheaders" +) + +// AccessType controls whether an object or bucket is publicly readable. +type AccessType string + +const ( + // AccessPrivate restricts access to authenticated callers (S3 canned ACL "private"). + AccessPrivate AccessType = "private" + // AccessPublic makes the object or bucket world-readable (S3 canned ACL "public-read"). + AccessPublic AccessType = "public" ) // ErrNoBucketName is returned when no bucket name is provided via the @@ -79,20 +95,98 @@ func WithPaginationToken(token string) ClientOption { } } -// WithContentType sets the Content-Type header for presigned PUT URLs. +// WithContentType sets the Content-Type header. Used by Put (the object +// Content-Type takes precedence when non-empty) and by presigned PUT URLs. func WithContentType(contentType string) ClientOption { return func(co *ClientOptions) { co.ContentType = aws.String(contentType) } } -// WithContentDisposition sets the Content-Disposition header for presigned PUT URLs. +// WithContentDisposition sets the Content-Disposition header for Put operations +// and presigned PUT URLs. func WithContentDisposition(disposition string) ClientOption { return func(co *ClientOptions) { co.ContentDisposition = aws.String(disposition) } } +// WithQuerySnapshotVersion specifies a snapshot version to query for Get, Head, or List operations. +// Use this to read from a specific bucket snapshot. +func WithQuerySnapshotVersion(version string) ClientOption { + return func(co *ClientOptions) { + co.SnapshotVersion = aws.String(version) + co.S3Options = append(co.S3Options, tigrisheaders.WithSnapshotVersion(version)) + } +} + +// WithResponseContentType overrides the Content-Type header in Get responses. +func WithResponseContentType(contentType string) ClientOption { + return func(co *ClientOptions) { + co.ResponseContentType = aws.String(contentType) + } +} + +// WithResponseContentDisposition overrides the Content-Disposition header in Get responses. +func WithResponseContentDisposition(disposition string) ClientOption { + return func(co *ClientOptions) { + co.ResponseContentDisposition = aws.String(disposition) + } +} + +// WithResponseCacheControl overrides the Cache-Control header in Get responses. +func WithResponseCacheControl(cacheControl string) ClientOption { + return func(co *ClientOptions) { + co.ResponseCacheControl = aws.String(cacheControl) + } +} + +// WithRandomSuffix adds a random suffix to the object key for uniqueness in Put operations. +func WithRandomSuffix() ClientOption { + return func(co *ClientOptions) { + co.RandomSuffix = true + } +} + +// WithAllowOverwrite controls whether overwrites are permitted in Put operations. +// When set to false, the operation will fail if the object already exists. +func WithAllowOverwrite(allow bool) ClientOption { + return func(co *ClientOptions) { + co.AllowOverwrite = aws.Bool(allow) + } +} + +// WithMultipartUpload enables multipart upload for objects whose Size exceeds +// the given threshold (in bytes). +func WithMultipartUpload(threshold int64) ClientOption { + return func(co *ClientOptions) { + co.MultipartThreshold = aws.Int64(threshold) + } +} + +// UploadProgress tracks upload progress. +type UploadProgress struct { + Loaded int64 // Bytes uploaded + Total int64 // Total bytes + Percentage float64 // Percentage complete +} + +// WithUploadProgress sets a callback to track upload progress in Put operations. +func WithUploadProgress(callback func(UploadProgress)) ClientOption { + return func(co *ClientOptions) { + co.UploadProgressCallback = callback + } +} + +// WithAccessType sets the canned ACL for Put operations. Use AccessPublic to +// make the uploaded object world-readable or AccessPrivate for authenticated-only +// access. If unset, the bucket's default ACL applies. +func WithAccessType(access AccessType) ClientOption { + return func(co *ClientOptions) { + co.AccessType = access + } +} + // ClientOptions is the collection of options that are set for individual Tigris // calls. type ClientOptions struct { @@ -106,9 +200,24 @@ type ClientOptions struct { Prefix *string PaginationToken *string - // Presign options + // Put and presign options ContentType *string ContentDisposition *string + + // Snapshot version for Get, Head, List operations + SnapshotVersion *string + + // Response override options for Get operations + ResponseContentType *string + ResponseContentDisposition *string + ResponseCacheControl *string + + // Put options + RandomSuffix bool + AllowOverwrite *bool + MultipartThreshold *int64 + UploadProgressCallback func(UploadProgress) + AccessType AccessType } // defaults populates client options from the global Options. @@ -201,9 +310,10 @@ type Object struct { // ListResult contains the result of a List operation, including pagination information. type ListResult struct { - Items []Object // List of objects - NextToken string // Pagination token for the next page - HasMore bool // Whether there are more objects to list + Items []Object // List of objects + CommonPrefixes []string // Common prefixes grouped by delimiter (populated when WithDelimiter is set) + NextToken string // Pagination token for the next page + HasMore bool // Whether there are more objects to list } // Get fetches the contents of an object and its metadata from Tigris. @@ -217,8 +327,11 @@ func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Ob resp, err := c.cli.GetObject( ctx, &s3.GetObjectInput{ - Bucket: aws.String(o.BucketName), - Key: aws.String(key), + Bucket: aws.String(o.BucketName), + Key: aws.String(key), + ResponseContentType: o.ResponseContentType, + ResponseContentDisposition: o.ResponseContentDisposition, + ResponseCacheControl: o.ResponseCacheControl, }, o.S3Options..., ) @@ -275,6 +388,10 @@ func (c *Client) Head(ctx context.Context, key string, opts ...ClientOption) (*O } // Put puts the contents of an object into Tigris. +// +// The returned *Object is the same pointer as obj with Bucket, Key, Etag, and +// Version populated from the response. When WithRandomSuffix is used, obj.Key +// is rewritten to the suffixed key actually stored. func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*Object, error) { o := new(ClientOptions).defaults(c.options) @@ -282,25 +399,77 @@ func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*O doer(&o) } - resp, err := c.cli.PutObject( - ctx, - &s3.PutObjectInput{ - Bucket: aws.String(o.BucketName), - Key: aws.String(obj.Key), - Body: obj.Body, - ContentType: raise(obj.ContentType), - ContentLength: raise(obj.Size), - }, - o.S3Options..., + key := obj.Key + if o.RandomSuffix { + suffix, err := generateRandomSuffix(12) + if err != nil { + return nil, fmt.Errorf("simplestorage: can't generate random suffix for %s/%s: %w", o.BucketName, key, err) + } + key = fmt.Sprintf("%s-%s", key, suffix) + } + + // Disallow overwrites server-side using If-Match: "" so the check is atomic. + if o.AllowOverwrite != nil && !*o.AllowOverwrite { + o.S3Options = append(o.S3Options, tigrisheaders.WithCreateObjectIfNotExists()) + } + + body := obj.Body + if o.UploadProgressCallback != nil && body != nil { + body = &progressReader{ + reader: body, + total: obj.Size, + callback: o.UploadProgressCallback, + } + } + + contentType := raise(obj.ContentType) + if contentType == nil { + contentType = o.ContentType + } + + useMultipart := o.MultipartThreshold != nil && obj.Size > *o.MultipartThreshold + + var ( + etag string + versionID string ) - if err != nil { - return nil, fmt.Errorf("simplestorage: can't put %s/%s: %v", o.BucketName, obj.Key, err) + if useMultipart { + tm := transfermanager.New(c.cli.S3()) + resp, err := tm.UploadObject(ctx, &transfermanager.UploadObjectInput{ + Bucket: aws.String(o.BucketName), + Key: aws.String(key), + Body: body, + ContentType: contentType, + ContentDisposition: o.ContentDisposition, + ACL: tmObjectACL(o.AccessType), + }) + if err != nil { + return nil, fmt.Errorf("simplestorage: can't put %s/%s: %v", o.BucketName, key, err) + } + etag = lower(resp.ETag, "") + versionID = lower(resp.VersionID, "") + } else { + resp, err := c.cli.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(o.BucketName), + Key: aws.String(key), + Body: body, + ContentType: contentType, + ContentDisposition: o.ContentDisposition, + ContentLength: raise(obj.Size), + ACL: objectACL(o.AccessType), + }, o.S3Options...) + if err != nil { + return nil, fmt.Errorf("simplestorage: can't put %s/%s: %v", o.BucketName, key, err) + } + etag = lower(resp.ETag, "") + versionID = lower(resp.VersionId, "") } obj.Bucket = o.BucketName - obj.Etag = lower(resp.ETag, "") - obj.Version = lower(resp.VersionId, "") + obj.Key = key + obj.Etag = etag + obj.Version = versionID return obj, nil } @@ -331,7 +500,8 @@ func (c *Client) Delete(ctx context.Context, key string, opts ...ClientOption) e // // The returned ListResult contains pagination information; use NextToken with // WithPaginationToken() to fetch the next page. HasMore indicates whether -// additional objects are available. +// additional objects are available. When WithDelimiter is set, CommonPrefixes +// is populated with the grouped prefixes (for directory-like listings). func (c *Client) List(ctx context.Context, opts ...ClientOption) (*ListResult, error) { o := new(ClientOptions).defaults(c.options) @@ -357,12 +527,11 @@ func (c *Client) List(ctx context.Context, opts ...ClientOption) (*ListResult, e } result := &ListResult{ - Items: make([]Object, 0, len(resp.Contents)), - HasMore: lower(resp.IsTruncated, false), + Items: make([]Object, 0, len(resp.Contents)), + NextToken: lower(resp.NextContinuationToken, ""), + HasMore: lower(resp.IsTruncated, false), } - result.NextToken = lower(resp.NextContinuationToken, "") - for _, obj := range resp.Contents { result.Items = append(result.Items, Object{ Bucket: o.BucketName, @@ -373,6 +542,15 @@ func (c *Client) List(ctx context.Context, opts ...ClientOption) (*ListResult, e }) } + if len(resp.CommonPrefixes) > 0 { + result.CommonPrefixes = make([]string, 0, len(resp.CommonPrefixes)) + for _, p := range resp.CommonPrefixes { + if p.Prefix != nil { + result.CommonPrefixes = append(result.CommonPrefixes, *p.Prefix) + } + } + } + return result, nil } @@ -387,33 +565,27 @@ func (c *Client) List(ctx context.Context, opts ...ClientOption) (*ListResult, e // // The expiry duration must be positive; the returned URL will only be valid for this duration. func (c *Client) PresignURL(ctx context.Context, method string, key string, expiry time.Duration, opts ...ClientOption) (string, error) { - // Validate HTTP method switch method { case http.MethodGet, http.MethodPut, http.MethodDelete: default: return "", fmt.Errorf("simplestorage: unsupported HTTP method %q for presigned URL (supported: GET, PUT, DELETE)", method) } - // Validate key if key == "" { return "", fmt.Errorf("simplestorage: key cannot be empty for presigned URL") } - // Validate expiry if expiry <= 0 { return "", fmt.Errorf("simplestorage: invalid expiry duration %v for presigned URL (must be positive)", expiry) } - // Build options o := new(ClientOptions).defaults(c.options) for _, doer := range opts { doer(&o) } - // Create presign client presignClient := s3.NewPresignClient(c.cli.Client) - // Route to appropriate presign method switch method { case http.MethodGet: return presignURLGet(ctx, presignClient, o.BucketName, key, expiry) @@ -426,6 +598,74 @@ func (c *Client) PresignURL(ctx context.Context, method string, key string, expi return "", nil // unreachable } +// generateRandomSuffix generates a random hexadecimal string of the specified length. +// Uses (length+1)/2 random bytes so odd lengths still produce a hex string at +// least length characters long before truncation. +func generateRandomSuffix(length int) (string, error) { + b := make([]byte, (length+1)/2) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + return hex.EncodeToString(b)[:length], nil +} + +// progressReader wraps an io.Reader to invoke a callback with cumulative +// progress after each Read. Retries may cause the callback to observe bytes +// more than once; callers should treat the Loaded counter as monotonic within +// a single attempt, not across the whole operation. +type progressReader struct { + reader io.Reader + total int64 + loaded int64 + callback func(UploadProgress) +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.reader.Read(p) + if n > 0 { + pr.loaded += int64(n) + var pct float64 + if pr.total > 0 { + pct = float64(pr.loaded) / float64(pr.total) * 100 + } + pr.callback(UploadProgress{Loaded: pr.loaded, Total: pr.total, Percentage: pct}) + } + return n, err +} + +// Close forwards to the underlying reader's Close method when present. +func (pr *progressReader) Close() error { + if rc, ok := pr.reader.(io.Closer); ok { + return rc.Close() + } + return nil +} + +// objectACL maps an AccessType to an S3 canned ACL for object operations. +// Returns an empty ACL when access is unset so the bucket default applies. +func objectACL(a AccessType) s3types.ObjectCannedACL { + switch a { + case AccessPublic: + return s3types.ObjectCannedACLPublicRead + case AccessPrivate: + return s3types.ObjectCannedACLPrivate + default: + return "" + } +} + +// tmObjectACL mirrors objectACL for the transfermanager package's ACL type. +func tmObjectACL(a AccessType) tmtypes.ObjectCannedACL { + switch a { + case AccessPublic: + return tmtypes.ObjectCannedACLPublicRead + case AccessPrivate: + return tmtypes.ObjectCannedACLPrivate + default: + return "" + } +} + // lower lowers the "pointer level" of the value by returning the value pointed // to by p, or defaultVal if p is nil. func lower[T any](p *T, defaultVal T) T { @@ -465,7 +705,6 @@ func presignURLPut(ctx context.Context, client *s3.PresignClient, bucket, key st Key: aws.String(key), } - // Apply optional headers if opts.ContentType != nil { input.ContentType = opts.ContentType } diff --git a/simplestorage/client_example_test.go b/simplestorage/client_example_test.go new file mode 100644 index 0000000..afe8a39 --- /dev/null +++ b/simplestorage/client_example_test.go @@ -0,0 +1,243 @@ +package simplestorage_test + +import ( + "context" + "fmt" + "io" + "log" + "strings" + + _ "github.com/joho/godotenv/autoload" + simplestorage "github.com/tigrisdata/storage-go/simplestorage" +) + +func ExampleClient_Head() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + info, err := client.Head(ctx, "reports/q1.pdf") + if err != nil { + log.Fatal(err) // handle the error here + } + + fmt.Printf("size=%d type=%s\n", info.Size, info.ContentType) +} + +func ExampleClient_Head_snapshotVersion() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + info, err := client.Head(ctx, "reports/q1.pdf", + simplestorage.WithQuerySnapshotVersion("2024-01-01T00:00:00Z"), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + _ = info +} + +func ExampleClient_Get_responseOverrides() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + // Force the response Content-Disposition so browsers download rather than render. + obj, err := client.Get(ctx, "reports/q1.pdf", + simplestorage.WithResponseContentDisposition(`attachment; filename="q1.pdf"`), + simplestorage.WithResponseContentType("application/pdf"), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + defer obj.Body.Close() + + _, err = io.Copy(io.Discard, obj.Body) + if err != nil { + log.Fatal(err) // handle the error here + } +} + +func ExampleClient_Put_publicAccess() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + body := strings.NewReader("hello world") + obj, err := client.Put(ctx, &simplestorage.Object{ + Key: "public/greeting.txt", + ContentType: "text/plain", + Size: int64(body.Len()), + Body: io.NopCloser(body), + }, + simplestorage.WithAccessType(simplestorage.AccessPublic), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + + fmt.Println(obj.Etag) +} + +func ExampleClient_Put_randomSuffix() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + body := strings.NewReader("payload") + // The stored key ends up like "uploads/image.png-" so concurrent + // uploads with the same base name don't collide. + obj, err := client.Put(ctx, &simplestorage.Object{ + Key: "uploads/image.png", + Body: io.NopCloser(body), + Size: int64(body.Len()), + }, + simplestorage.WithRandomSuffix(), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + + fmt.Println(obj.Key) +} + +func ExampleClient_Put_noOverwrite() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + body := strings.NewReader("first write wins") + _, err = client.Put(ctx, &simplestorage.Object{ + Key: "config/seed.json", + Body: io.NopCloser(body), + Size: int64(body.Len()), + }, + simplestorage.WithAllowOverwrite(false), + ) + if err != nil { + log.Fatal(err) // handle the error here + } +} + +func ExampleClient_Put_uploadProgress() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + body := strings.NewReader(strings.Repeat("x", 1024)) + _, err = client.Put(ctx, &simplestorage.Object{ + Key: "reports/large.bin", + Body: io.NopCloser(body), + Size: int64(body.Len()), + }, + simplestorage.WithUploadProgress(func(p simplestorage.UploadProgress) { + fmt.Printf("uploaded %d of %d bytes (%.1f%%)\n", p.Loaded, p.Total, p.Percentage) + }), + ) + if err != nil { + log.Fatal(err) // handle the error here + } +} + +func ExampleClient_List_delimiter() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + // Walk one "directory" level under prefix "reports/" using "/" as a delimiter. + result, err := client.List(ctx, + simplestorage.WithPrefix("reports/"), + simplestorage.WithDelimiter("/"), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + + for _, p := range result.CommonPrefixes { + fmt.Println("sub-prefix:", p) + } + for _, o := range result.Items { + fmt.Println("object:", o.Key) + } +} + +func ExampleWithBucketAccess() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-default-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + // Public-read bucket: objects inside are world-readable unless overridden. + info, err := client.CreateBucket(ctx, "public-assets", + simplestorage.WithBucketAccess(simplestorage.AccessPublic), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + _ = info +} + +func ExampleWithDefaultTier() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-default-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + // Archive-tier bucket for cold storage. + info, err := client.CreateBucket(ctx, "cold-archive", + simplestorage.WithDefaultTier("GLACIER"), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + _ = info +} diff --git a/simplestorage/client_options_test.go b/simplestorage/client_options_test.go new file mode 100644 index 0000000..b60ab5b --- /dev/null +++ b/simplestorage/client_options_test.go @@ -0,0 +1,181 @@ +package simplestorage + +import ( + "io" + "strings" + "testing" +) + +func TestProgressReader(t *testing.T) { + const payload = "hello world" + var reports []UploadProgress + + pr := &progressReader{ + reader: strings.NewReader(payload), + total: int64(len(payload)), + callback: func(p UploadProgress) { reports = append(reports, p) }, + } + + got, err := io.ReadAll(pr) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(got) != payload { + t.Fatalf("payload = %q, want %q", got, payload) + } + if len(reports) == 0 { + t.Fatal("expected at least one progress report") + } + + last := reports[len(reports)-1] + if last.Loaded != int64(len(payload)) { + t.Errorf("last.Loaded = %d, want %d", last.Loaded, len(payload)) + } + if last.Total != int64(len(payload)) { + t.Errorf("last.Total = %d, want %d", last.Total, len(payload)) + } + if last.Percentage != 100.0 { + t.Errorf("last.Percentage = %f, want 100.0", last.Percentage) + } +} + +func TestClientOptions(t *testing.T) { + tests := []struct { + name string + option ClientOption + verify func(*testing.T, *ClientOptions) + }{ + { + name: "WithDelimiter sets Delimiter", + option: WithDelimiter("/"), + verify: func(t *testing.T, o *ClientOptions) { + if o.Delimiter == nil || *o.Delimiter != "/" { + t.Errorf("Delimiter = %v, want %q", o.Delimiter, "/") + } + }, + }, + { + name: "WithQuerySnapshotVersion sets SnapshotVersion", + option: WithQuerySnapshotVersion("test-version"), + verify: func(t *testing.T, o *ClientOptions) { + if o.SnapshotVersion == nil || *o.SnapshotVersion != "test-version" { + t.Errorf("SnapshotVersion = %v, want %q", o.SnapshotVersion, "test-version") + } + }, + }, + { + name: "WithResponseContentType sets ResponseContentType", + option: WithResponseContentType("text/plain"), + verify: func(t *testing.T, o *ClientOptions) { + if o.ResponseContentType == nil || *o.ResponseContentType != "text/plain" { + t.Errorf("ResponseContentType = %v, want %q", o.ResponseContentType, "text/plain") + } + }, + }, + { + name: "WithResponseContentDisposition sets ResponseContentDisposition", + option: WithResponseContentDisposition("attachment"), + verify: func(t *testing.T, o *ClientOptions) { + if o.ResponseContentDisposition == nil || *o.ResponseContentDisposition != "attachment" { + t.Errorf("ResponseContentDisposition = %v, want %q", o.ResponseContentDisposition, "attachment") + } + }, + }, + { + name: "WithResponseCacheControl sets ResponseCacheControl", + option: WithResponseCacheControl("no-cache"), + verify: func(t *testing.T, o *ClientOptions) { + if o.ResponseCacheControl == nil || *o.ResponseCacheControl != "no-cache" { + t.Errorf("ResponseCacheControl = %v, want %q", o.ResponseCacheControl, "no-cache") + } + }, + }, + { + name: "WithRandomSuffix sets RandomSuffix to true", + option: WithRandomSuffix(), + verify: func(t *testing.T, o *ClientOptions) { + if !o.RandomSuffix { + t.Errorf("RandomSuffix = false, want true") + } + }, + }, + { + name: "WithAllowOverwrite sets AllowOverwrite", + option: WithAllowOverwrite(false), + verify: func(t *testing.T, o *ClientOptions) { + if o.AllowOverwrite == nil || *o.AllowOverwrite != false { + t.Errorf("AllowOverwrite = %v, want false", o.AllowOverwrite) + } + }, + }, + { + name: "WithContentDisposition sets ContentDisposition", + option: WithContentDisposition("attachment; filename=test.txt"), + verify: func(t *testing.T, o *ClientOptions) { + if o.ContentDisposition == nil || *o.ContentDisposition != "attachment; filename=test.txt" { + t.Errorf("ContentDisposition = %v, want %q", o.ContentDisposition, "attachment; filename=test.txt") + } + }, + }, + { + name: "WithMultipartUpload sets MultipartThreshold", + option: WithMultipartUpload(5242880), + verify: func(t *testing.T, o *ClientOptions) { + if o.MultipartThreshold == nil || *o.MultipartThreshold != 5242880 { + t.Errorf("MultipartThreshold = %v, want 5242880", o.MultipartThreshold) + } + }, + }, + { + name: "WithUploadProgress sets UploadProgressCallback", + option: WithUploadProgress(func(p UploadProgress) {}), + verify: func(t *testing.T, o *ClientOptions) { + if o.UploadProgressCallback == nil { + t.Errorf("UploadProgressCallback = nil, want non-nil") + } + }, + }, + { + name: "WithPrefix sets Prefix", + option: WithPrefix("test-prefix/"), + verify: func(t *testing.T, o *ClientOptions) { + if o.Prefix == nil || *o.Prefix != "test-prefix/" { + t.Errorf("Prefix = %v, want %q", o.Prefix, "test-prefix/") + } + }, + }, + { + name: "WithAccessType sets AccessType", + option: WithAccessType(AccessPublic), + verify: func(t *testing.T, o *ClientOptions) { + if o.AccessType != AccessPublic { + t.Errorf("AccessType = %v, want %v", o.AccessType, AccessPublic) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := new(ClientOptions).defaults(Options{}) + tt.option(&o) + tt.verify(t, &o) + }) + } +} + +func TestBucketOption_WithBucketAccess(t *testing.T) { + o := new(BucketOptions).defaults() + WithBucketAccess(AccessPublic)(&o) + + if o.Access != AccessPublic { + t.Errorf("Access = %v, want %v", o.Access, AccessPublic) + } +} + +func TestBucketOptionsDefaultsAccessIsPrivate(t *testing.T) { + o := new(BucketOptions).defaults() + if o.Access != AccessPrivate { + t.Errorf("default Access = %q, want %q", o.Access, AccessPrivate) + } +} diff --git a/tigrisheaders/tigrisheaders.go b/tigrisheaders/tigrisheaders.go index 5a90ca8..bf5db42 100644 --- a/tigrisheaders/tigrisheaders.go +++ b/tigrisheaders/tigrisheaders.go @@ -121,6 +121,22 @@ func WithEnableSnapshot() func(*s3.Options) { return WithHeader("X-Tigris-Enable-Snapshot", "true") } +// WithStorageClass sets the storage class tier for buckets or objects. +// Valid values: "STANDARD", "STANDARD_IA", "GLACIER", "GLACIER_IR" +func WithStorageClass(storageClass string) func(*s3.Options) { + return WithHeader("X-Tigris-Storage-Class", storageClass) +} + +// WithConsistentRead enables consistent read mode. +func WithConsistentRead() func(*s3.Options) { + return WithHeader("X-Tigris-Consistent", "true") +} + +// WithForkSourceBucketSnapshot specifies the snapshot version when forking from a bucket. +func WithForkSourceBucketSnapshot(snapshot string) func(*s3.Options) { + return WithHeader("X-Tigris-Fork-Source-Bucket-Snapshot", snapshot) +} + // WithTakeSnapshot tells Tigris to create a snapshot with the given description on a forkable bucket. // // See the Tigris documentation[1] for more information. @@ -169,4 +185,3 @@ func WithRename() func(*s3.Options) { options.APIOptions = append(options.APIOptions, http.AddHeaderValue("X-Tigris-Rename", "true")) } } -