From 7c4a1b37227ce6686476aa87cd6a8eb1f2e7935b Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 12 Jan 2026 12:34:30 -0500 Subject: [PATCH 1/6] feat(go): add simplestorage package for high-level interactions Not totally sure about the API yet, but I think this will be fine? Signed-off-by: Xe Iaso --- go/README.md | 4 +- go/client.go | 4 +- go/example_test.go | 4 +- go/simplestorage/client.go | 238 ++++++++++++++++++++++++++++++++++++ go/simplestorage/options.go | 99 +++++++++++++++ go/storage.go | 38 +++++- 6 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 go/simplestorage/client.go create mode 100644 go/simplestorage/options.go diff --git a/go/README.md b/go/README.md index d3cc030..0b0362c 100644 --- a/go/README.md +++ b/go/README.md @@ -73,10 +73,10 @@ client, err := storage.New(ctx, Tigris supports bucket snapshots and forking, allowing you to create point-in-time copies of buckets and branch from them. -#### Create a Snapshottable Bucket +#### Create a Snapshot Enabled Bucket ```go -output, err := client.CreateSnapshottableBucket(ctx, &s3.CreateBucketInput{ +output, err := client.CreateSnapshotEnabledBucket(ctx, &s3.CreateBucketInput{ Bucket: aws.String("my-bucket"), }) ``` diff --git a/go/client.go b/go/client.go index 74c0ff9..661afad 100644 --- a/go/client.go +++ b/go/client.go @@ -34,8 +34,8 @@ func (c *Client) CreateBucketSnapshot(ctx context.Context, description string, i return c.Client.CreateBucket(ctx, in, opts...) } -// CreateSnapshottableBucket creates a new bucket with the ability to take snapshots and fork the contents of it. -func (c *Client) CreateSnapshottableBucket(ctx context.Context, in *s3.CreateBucketInput, opts ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { +// CreateSnapshotEnabledBucket creates a new bucket with the ability to take snapshots and fork the contents of it. +func (c *Client) CreateSnapshotEnabledBucket(ctx context.Context, in *s3.CreateBucketInput, opts ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { opts = append(opts, tigrisheaders.WithEnableSnapshot()) return c.Client.CreateBucket(ctx, in, opts...) diff --git a/go/example_test.go b/go/example_test.go index a76c4a5..5ea7388 100644 --- a/go/example_test.go +++ b/go/example_test.go @@ -32,7 +32,7 @@ func ExampleNew() { _ = client } -func ExampleClient_CreateSnapshottableBucket() { +func ExampleClient_CreateSnapshotEnabledBucket() { ctx := context.Background() client, err := storage.New(ctx) @@ -41,7 +41,7 @@ func ExampleClient_CreateSnapshottableBucket() { } // Create a bucket with snapshot support enabled - output, err := client.CreateSnapshottableBucket(ctx, &s3.CreateBucketInput{ + output, err := client.CreateSnapshotEnabledBucket(ctx, &s3.CreateBucketInput{ Bucket: aws.String("my-bucket"), }) if err != nil { diff --git a/go/simplestorage/client.go b/go/simplestorage/client.go new file mode 100644 index 0000000..6751639 --- /dev/null +++ b/go/simplestorage/client.go @@ -0,0 +1,238 @@ +package simplestorage + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + storage "github.com/tigrisdata/storage/go" +) + +type Client struct { + cli *storage.Client + options Options +} + +type ClientOption func(*ClientOptions) + +func WithBucket(bucket string) ClientOption { + return func(co *ClientOptions) { + co.BucketName = bucket + } +} + +func WithS3Headers(opts ...func(*s3.Options)) ClientOption { + return func(co *ClientOptions) { + co.S3Headers = append(co.S3Headers, opts...) + } +} + +func WithStartAfter(startAfter string) ClientOption { + return func(co *ClientOptions) { + co.StartAfter = aws.String(startAfter) + } +} + +func WithMaxKeys(maxKeys int32) ClientOption { + return func(co *ClientOptions) { + co.MaxKeys = &maxKeys + } +} + +type ClientOptions struct { + BucketName string + S3Headers []func(*s3.Options) + + // List options + StartAfter *string + MaxKeys *int32 +} + +func (ClientOptions) defaults(o Options) ClientOptions { + return ClientOptions{ + BucketName: o.BucketName, + } +} + +func New(ctx context.Context, options ...Option) (*Client, error) { + o := new(Options).defaults() + + for _, doer := range options { + doer(&o) + } + + var storageOpts []storage.Option + + if o.BaseEndpoint != storage.GlobalEndpoint { + storageOpts = append(storageOpts, storage.WithEndpoint(o.BaseEndpoint)) + } + + storageOpts = append(storageOpts, storage.WithRegion(o.Region)) + storageOpts = append(storageOpts, storage.WithPathStyle(o.UsePathStyle)) + + if o.AccessKeyID != "" && o.SecretAccessKey != "" { + storageOpts = append(storageOpts, storage.WithAccessKeypair(o.AccessKeyID, o.SecretAccessKey)) + } + + cli, err := storage.New(ctx, storageOpts...) + if err != nil { + return nil, fmt.Errorf("simplestorage: can't create storage client: %w", err) + } + + return &Client{ + cli: cli, + options: o, + }, nil +} + +type Object struct { + Bucket string // Bucket the object is in + Key string // Key for the object + ContentType string // MIME type for the object or application/octet-stream + Etag string // Entity tag for the object (usually a checksum) + Version string // Version tag for the object + Size int64 // Size of the object in bytes or 0 if unknown + LastModified time.Time // Creation date of the object + Body io.ReadCloser // Body of the object so it can be read, don't forget to close it. +} + +func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Object, error) { + o := new(ClientOptions).defaults(c.options) + + for _, doer := range opts { + doer(&o) + } + + resp, err := c.cli.GetObject( + ctx, + &s3.GetObjectInput{ + Bucket: aws.String(o.BucketName), + Key: aws.String(key), + }, + o.S3Headers..., + ) + + if err != nil { + return nil, fmt.Errorf("simplestorage: can't get %s/%s: %v", o.BucketName, key, err) + } + + return &Object{ + Bucket: o.BucketName, + Key: key, + ContentType: Lower(resp.ContentType, "application/octet-stream"), + Etag: Lower(resp.ETag, ""), + Size: Lower(resp.ContentLength, 0), + Version: Lower(resp.VersionId, ""), + LastModified: Lower(resp.LastModified, time.Unix(1, 1)), + Body: resp.Body, + }, nil +} + +func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*Object, error) { + o := new(ClientOptions).defaults(c.options) + + for _, doer := range opts { + 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.S3Headers..., + ) + + if err != nil { + return nil, fmt.Errorf("simplestorage: can't put %s/%s: %v", o.BucketName, obj.Key, err) + } + + obj.Bucket = o.BucketName + obj.Etag = *resp.ETag + obj.Version = *resp.VersionId + + return obj, nil +} + +func (c *Client) Delete(ctx context.Context, key string, opts ...ClientOption) error { + o := new(ClientOptions).defaults(c.options) + + for _, doer := range opts { + doer(&o) + } + + if _, err := c.cli.DeleteObject( + ctx, + &s3.DeleteObjectInput{ + Bucket: aws.String(o.BucketName), + Key: aws.String(key), + }, + o.S3Headers..., + ); err != nil { + return fmt.Errorf("simplestorage: can't delete %s/%s: %v", o.BucketName, key, err) + } + + return nil +} + +func (c *Client) List(ctx context.Context, prefix string, opts ...ClientOption) ([]Object, error) { + o := new(ClientOptions).defaults(c.options) + + for _, doer := range opts { + doer(&o) + } + + resp, err := c.cli.ListObjectsV2( + ctx, + &s3.ListObjectsV2Input{ + Bucket: aws.String(o.BucketName), + Prefix: aws.String(prefix), + + MaxKeys: o.MaxKeys, + StartAfter: o.StartAfter, + }, + o.S3Headers..., + ) + + if err != nil { + return nil, fmt.Errorf("simplestorage: can't list %s/%s: %v", o.BucketName, prefix, err) + } + + var result []Object + + for _, obj := range resp.Contents { + result = append(result, Object{ + Bucket: o.BucketName, + Key: *obj.Key, + Etag: Lower(obj.ETag, ""), + Size: Lower(obj.Size, 0), + LastModified: Lower(obj.LastModified, time.Unix(1, 1)), + }) + } + + return result, nil +} + +// Lower returns the value pointed to by p, or defaultVal if p is nil. +func Lower[T any](p *T, defaultVal T) T { + if p != nil { + return *p + } + return defaultVal +} + +// Raise returns a pointer to v, or nil if v is the zero value for type T. +func Raise[T comparable](v T) *T { + var zero T + if v == zero { + return nil + } + return &v +} diff --git a/go/simplestorage/options.go b/go/simplestorage/options.go new file mode 100644 index 0000000..886d194 --- /dev/null +++ b/go/simplestorage/options.go @@ -0,0 +1,99 @@ +package simplestorage + +import ( + "os" + + storage "github.com/tigrisdata/storage/go" +) + +type Option func(o *Options) + +type Options struct { + BucketName string + AccessKeyID string + SecretAccessKey string + + BaseEndpoint string + Region string + UsePathStyle bool +} + +func (Options) defaults() Options { + return Options{ + BucketName: os.Getenv("TIGRIS_STORAGE_BUCKET"), + AccessKeyID: os.Getenv("TIGRIS_STORAGE_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("TIGRIS_STORAGE_SECRET_ACCESS_KEY"), + + BaseEndpoint: storage.GlobalEndpoint, + Region: "auto", + UsePathStyle: false, + } +} + +// WithFlyEndpoint lets you connect to Tigris' fly.io optimized endpoint. +// +// If you are deployed to fly.io, this zero-rates your traffic to Tigris. +// +// If you are not deployed to fly.io, please use WithGlobalEndpoint instead. +func WithFlyEndpoint() Option { + return func(o *Options) { + o.BaseEndpoint = storage.FlyEndpoint + } +} + +// WithGlobalEndpoint lets you connect to Tigris' globally available endpoint. +// +// If you are deployed to fly.io, please use WithFlyEndpoint instead. +func WithGlobalEndpoint() Option { + return func(o *Options) { + o.BaseEndpoint = storage.GlobalEndpoint + } +} + +// WithEndpoint sets a custom endpoint for connecting to Tigris. +// +// This allows you to connect to a custom Tigris endpoint instead of the default +// global endpoint. Use this for: +// - Using a custom proxy or gateway +// - Testing against local development endpoints +// +// For most use cases, consider using WithGlobalEndpoint or WithFlyEndpoint instead. +func WithEndpoint(endpoint string) Option { + return func(o *Options) { + o.BaseEndpoint = endpoint + } +} + +// WithRegion lets you statically specify a region for interacting with Tigris. +// +// You will almost certainly never need this. This is here for development usecases where the default region is not "auto". +func WithRegion(region string) Option { + return func(o *Options) { + o.Region = region + } +} + +// WithPathStyle configures whether to use path-style addressing for S3 requests. +// +// By default, Tigris uses virtual-hosted-style addressing (e.g., https://bucket.t3.storage.dev). +// Path-style addressing (e.g., https://t3.storage.dev/bucket) may be needed for: +// - Compatibility with older S3 clients that don't support virtual-hosted-style +// - Working through certain proxies or load balancers that don't support virtual-hosted-style +// - Local development environments with custom DNS setups +// +// Enable this only if you encounter issues with the default virtual-hosted-style addressing. +func WithPathStyle(enabled bool) Option { + return func(o *Options) { + o.UsePathStyle = enabled + } +} + +// WithAccessKeypair lets you specify a custom access key and secret access key for interfacing with Tigris. +// +// This is useful when you need to load environment variables from somewhere other than the default AWS configuration path. +func WithAccessKeypair(accessKeyID, secretAccessKey string) Option { + return func(o *Options) { + o.AccessKeyID = accessKeyID + o.SecretAccessKey = secretAccessKey + } +} diff --git a/go/storage.go b/go/storage.go index a61fd70..6c2a4b7 100644 --- a/go/storage.go +++ b/go/storage.go @@ -13,6 +13,11 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" ) +const ( + GlobalEndpoint = "https://t3.storage.dev" + FlyEndpoint = "https://fly.storage.tigris.dev" +) + // Options is the set of options that can be configured for the Tigris client. type Options struct { BaseEndpoint string @@ -42,7 +47,7 @@ type Option func(o *Options) // If you are not deployed to fly.io, please use WithGlobalEndpoint instead. func WithFlyEndpoint() Option { return func(o *Options) { - o.BaseEndpoint = "https://fly.storage.tigris.dev" + o.BaseEndpoint = FlyEndpoint } } @@ -51,7 +56,21 @@ func WithFlyEndpoint() Option { // If you are deployed to fly.io, please use WithFlyEndpoint instead. func WithGlobalEndpoint() Option { return func(o *Options) { - o.BaseEndpoint = "https://t3.storage.dev" + o.BaseEndpoint = GlobalEndpoint + } +} + +// WithEndpoint sets a custom endpoint for connecting to Tigris. +// +// This allows you to connect to a custom Tigris endpoint instead of the default +// global endpoint. Use this for: +// - Using a custom proxy or gateway +// - Testing against local development endpoints +// +// For most use cases, consider using WithGlobalEndpoint or WithFlyEndpoint instead. +func WithEndpoint(endpoint string) Option { + return func(o *Options) { + o.BaseEndpoint = endpoint } } @@ -64,6 +83,21 @@ func WithRegion(region string) Option { } } +// WithPathStyle configures whether to use path-style addressing for S3 requests. +// +// By default, Tigris uses virtual-hosted-style addressing (e.g., https://bucket.t3.storage.dev). +// Path-style addressing (e.g., https://t3.storage.dev/bucket) may be needed for: +// - Compatibility with older S3 clients that don't support virtual-hosted-style +// - Working through certain proxies or load balancers that don't support virtual-hosted-style +// - Local development environments with custom DNS setups +// +// Enable this only if you encounter issues with the default virtual-hosted-style addressing. +func WithPathStyle(enabled bool) Option { + return func(o *Options) { + o.UsePathStyle = enabled + } +} + // WithAccessKeypair lets you specify a custom access key and secret access key for interfacing with Tigris. // // This is useful when you need to load environment variables from somewhere other than the default AWS configuration path. From 59424d78872d4a9c0a17fc2603571979ac3cd160 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 12 Jan 2026 12:45:49 -0500 Subject: [PATCH 2/6] feat(go): load credentials from environment variables by default The low-level storage client now defaults to reading credentials from TIGRIS_STORAGE_ACCESS_KEY_ID and TIGRIS_STORAGE_SECRET_ACCESS_KEY environment variables, matching the behavior of the simplestorage package. Assisted-by: GLM 4.7 via Claude Code --- go/storage.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/go/storage.go b/go/storage.go index 6c2a4b7..b9aab89 100644 --- a/go/storage.go +++ b/go/storage.go @@ -6,6 +6,7 @@ package storage import ( "context" "fmt" + "os" "github.com/aws/aws-sdk-go-v2/aws" awsConfig "github.com/aws/aws-sdk-go-v2/config" @@ -31,9 +32,11 @@ type Options struct { // defaults returns the default configuration data for the Tigris client. func (Options) defaults() Options { return Options{ - BaseEndpoint: "https://t3.storage.dev", - Region: "auto", - UsePathStyle: false, + BaseEndpoint: "https://t3.storage.dev", + Region: "auto", + UsePathStyle: false, + AccessKeyID: os.Getenv("TIGRIS_STORAGE_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("TIGRIS_STORAGE_SECRET_ACCESS_KEY"), } } From e4f15c2ba08d370a4944db8e797ecb146314f320 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 12 Jan 2026 12:45:58 -0500 Subject: [PATCH 3/6] fix(simplestorage): use correct zero value for time fields Use time.Time{} instead of time.Unix(1, 1) as the default zero value for LastModified fields. Assisted-by: GLM 4.7 via Claude Code --- go/simplestorage/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/simplestorage/client.go b/go/simplestorage/client.go index 6751639..01a467e 100644 --- a/go/simplestorage/client.go +++ b/go/simplestorage/client.go @@ -126,7 +126,7 @@ func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Ob Etag: Lower(resp.ETag, ""), Size: Lower(resp.ContentLength, 0), Version: Lower(resp.VersionId, ""), - LastModified: Lower(resp.LastModified, time.Unix(1, 1)), + LastModified: Lower(resp.LastModified, time.Time{}), Body: resp.Body, }, nil } @@ -213,7 +213,7 @@ func (c *Client) List(ctx context.Context, prefix string, opts ...ClientOption) Key: *obj.Key, Etag: Lower(obj.ETag, ""), Size: Lower(obj.Size, 0), - LastModified: Lower(obj.LastModified, time.Unix(1, 1)), + LastModified: Lower(obj.LastModified, time.Time{}), }) } From 5883078f55805d9fd5d5404c8957be380bde19de Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 12 Jan 2026 12:55:20 -0500 Subject: [PATCH 4/6] fix(simplestorage): hide raise() and lower() Signed-off-by: Xe Iaso --- go/simplestorage/client.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/go/simplestorage/client.go b/go/simplestorage/client.go index 01a467e..a63654b 100644 --- a/go/simplestorage/client.go +++ b/go/simplestorage/client.go @@ -122,11 +122,11 @@ func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Ob return &Object{ Bucket: o.BucketName, Key: key, - ContentType: Lower(resp.ContentType, "application/octet-stream"), - Etag: Lower(resp.ETag, ""), - Size: Lower(resp.ContentLength, 0), - Version: Lower(resp.VersionId, ""), - LastModified: Lower(resp.LastModified, time.Time{}), + ContentType: lower(resp.ContentType, "application/octet-stream"), + Etag: lower(resp.ETag, ""), + Size: lower(resp.ContentLength, 0), + Version: lower(resp.VersionId, ""), + LastModified: lower(resp.LastModified, time.Time{}), Body: resp.Body, }, nil } @@ -144,8 +144,8 @@ func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*O Bucket: aws.String(o.BucketName), Key: aws.String(obj.Key), Body: obj.Body, - ContentType: Raise(obj.ContentType), - ContentLength: Raise(obj.Size), + ContentType: raise(obj.ContentType), + ContentLength: raise(obj.Size), }, o.S3Headers..., ) @@ -211,25 +211,27 @@ func (c *Client) List(ctx context.Context, prefix string, opts ...ClientOption) result = append(result, Object{ Bucket: o.BucketName, Key: *obj.Key, - Etag: Lower(obj.ETag, ""), - Size: Lower(obj.Size, 0), - LastModified: Lower(obj.LastModified, time.Time{}), + Etag: lower(obj.ETag, ""), + Size: lower(obj.Size, 0), + LastModified: lower(obj.LastModified, time.Time{}), }) } return result, nil } -// Lower returns the value pointed to by p, or defaultVal if p is nil. -func Lower[T any](p *T, defaultVal T) T { +// 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 { if p != nil { return *p } return defaultVal } -// Raise returns a pointer to v, or nil if v is the zero value for type T. -func Raise[T comparable](v T) *T { +// raise raises the "pointer level" of the value by returning a pointer to v, +// or nil if v is the zero value for type T. +func raise[T comparable](v T) *T { var zero T if v == zero { return nil From 90cd5359c1d6fc6485fa6dabb3b3542c7d1cb02c Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 12 Jan 2026 12:56:29 -0500 Subject: [PATCH 5/6] fix(simplestorage): validate bucket name in New function Add validation to ensure BucketName is non-empty when creating a client. If no bucket is provided via TIGRIS_STORAGE_BUCKET env var or WithBucket option, return ErrNoBucketName with a clear error message. Also add table-driven tests using errors.Is to verify the validation. Assisted-by: GLM 4.7 via Claude Code --- go/simplestorage/client.go | 14 +++++ go/simplestorage/client_test.go | 108 ++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 go/simplestorage/client_test.go diff --git a/go/simplestorage/client.go b/go/simplestorage/client.go index a63654b..2ead032 100644 --- a/go/simplestorage/client.go +++ b/go/simplestorage/client.go @@ -2,6 +2,7 @@ package simplestorage import ( "context" + "errors" "fmt" "io" "time" @@ -11,6 +12,10 @@ import ( storage "github.com/tigrisdata/storage/go" ) +// ErrNoBucketName is returned when no bucket name is provided via the +// TIGRIS_STORAGE_BUCKET environment variable or the WithBucket option. +var ErrNoBucketName = errors.New("bucket name not set: provide the TIGRIS_STORAGE_BUCKET environment variable or use WithBucket option") + type Client struct { cli *storage.Client options Options @@ -64,6 +69,15 @@ func New(ctx context.Context, options ...Option) (*Client, error) { doer(&o) } + var errs []error + if o.BucketName == "" { + errs = append(errs, ErrNoBucketName) + } + + if len(errs) != 0 { + return nil, fmt.Errorf("simplestorage: can't create client: %w", errors.Join(errs...)) + } + var storageOpts []storage.Option if o.BaseEndpoint != storage.GlobalEndpoint { diff --git a/go/simplestorage/client_test.go b/go/simplestorage/client_test.go new file mode 100644 index 0000000..135a0bd --- /dev/null +++ b/go/simplestorage/client_test.go @@ -0,0 +1,108 @@ +package simplestorage + +import ( + "context" + "errors" + "os" + "testing" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + setupEnv func() func() + options []Option + wantErr error + errCheck func(error) bool + }{ + { + name: "bucket from env var", + setupEnv: func() func() { + os.Setenv("TIGRIS_STORAGE_BUCKET", "test-bucket") + return func() { os.Unsetenv("TIGRIS_STORAGE_BUCKET") } + }, + wantErr: nil, + }, + { + name: "bucket from option overrides env var", + setupEnv: func() func() { + os.Setenv("TIGRIS_STORAGE_BUCKET", "env-bucket") + return func() { os.Unsetenv("TIGRIS_STORAGE_BUCKET") } + }, + options: []Option{ + func(o *Options) { o.BucketName = "option-bucket" }, + }, + wantErr: nil, + }, + { + name: "no bucket name returns ErrNoBucketName", + setupEnv: func() func() { return func() {} }, + wantErr: ErrNoBucketName, + errCheck: func(err error) bool { + return errors.Is(err, ErrNoBucketName) + }, + }, + { + name: "empty bucket from env var returns ErrNoBucketName", + setupEnv: func() func() { + os.Setenv("TIGRIS_STORAGE_BUCKET", "") + return func() { os.Unsetenv("TIGRIS_STORAGE_BUCKET") } + }, + wantErr: ErrNoBucketName, + errCheck: func(err error) bool { + return errors.Is(err, ErrNoBucketName) + }, + }, + { + name: "empty bucket from option returns ErrNoBucketName", + setupEnv: func() func() { + os.Unsetenv("TIGRIS_STORAGE_BUCKET") + return func() {} + }, + options: []Option{ + func(o *Options) { o.BucketName = "" }, + }, + wantErr: ErrNoBucketName, + errCheck: func(err error) bool { + return errors.Is(err, ErrNoBucketName) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := tt.setupEnv() + defer cleanup() + + // Clear access key env vars to avoid needing real credentials for this test + os.Unsetenv("TIGRIS_STORAGE_ACCESS_KEY_ID") + os.Unsetenv("TIGRIS_STORAGE_SECRET_ACCESS_KEY") + + // Use test endpoint options to avoid real network calls + opts := append(tt.options, + func(o *Options) { o.BaseEndpoint = "https://test.endpoint.dev" }, + func(o *Options) { o.Region = "auto" }, + ) + + _, err := New(context.Background(), opts...) + + if tt.wantErr != nil { + if err == nil { + t.Errorf("New() expected error containing %v, got nil", tt.wantErr) + return + } + if tt.errCheck != nil { + if !tt.errCheck(err) { + t.Errorf("New() error = %v, want error matching %v", err, tt.wantErr) + } + } else if !errors.Is(err, tt.wantErr) && !errors.Is(err, ErrNoBucketName) { + // Allow either the specific error or ErrNoBucketName for simpler test cases + t.Errorf("New() error = %v, want %v", err, tt.wantErr) + } + } else if err != nil && !errors.Is(err, ErrNoBucketName) { + // Ignore ErrNoBucketName since we're testing with fake endpoints + // In real tests with proper credentials, this would succeed + t.Logf("New() returned expected error with test endpoint: %v", err) + } + }) + } +} \ No newline at end of file From 18ef5716aaf62a8ffc385da6aed435a380ca6a7e Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 12 Jan 2026 13:17:07 -0500 Subject: [PATCH 6/6] docs(simplestorage): add documentation for all publicly exposed symbols Signed-off-by: Xe Iaso --- go/simplestorage/client.go | 47 ++++++++++++++++++++++++++++++------- go/simplestorage/options.go | 31 ++++++++++++++++++++---- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/go/simplestorage/client.go b/go/simplestorage/client.go index 2ead032..a737d56 100644 --- a/go/simplestorage/client.go +++ b/go/simplestorage/client.go @@ -16,52 +16,75 @@ import ( // TIGRIS_STORAGE_BUCKET environment variable or the WithBucket option. var ErrNoBucketName = errors.New("bucket name not set: provide the TIGRIS_STORAGE_BUCKET environment variable or use WithBucket option") +// Client is a high-level client for Tigris that simplifies common interactions +// to very high level calls. type Client struct { cli *storage.Client options Options } +// ClientOption is a function option that allows callers to override settings in +// calls to Tigris via Client. type ClientOption func(*ClientOptions) -func WithBucket(bucket string) ClientOption { +// OverrideBucket overrides the bucket used for Tigris calls. +func OverrideBucket(bucket string) ClientOption { return func(co *ClientOptions) { co.BucketName = bucket } } -func WithS3Headers(opts ...func(*s3.Options)) ClientOption { +// WithS3Options sets S3 options for individual Tigris calls. +func WithS3Options(opts ...func(*s3.Options)) ClientOption { return func(co *ClientOptions) { - co.S3Headers = append(co.S3Headers, opts...) + co.S3Options = append(co.S3Options, opts...) } } +// WithStartAfter sets the StartAfter setting in List calls. Use this if you need +// pagination in your List calls. func WithStartAfter(startAfter string) ClientOption { return func(co *ClientOptions) { co.StartAfter = aws.String(startAfter) } } +// WithMaxKeys sets the maximum number of keys in List calls. Use this along with +// WithStartAfter for pagination in your List calls. func WithMaxKeys(maxKeys int32) ClientOption { return func(co *ClientOptions) { co.MaxKeys = &maxKeys } } +// ClientOptions is the collection of options that are set for individual Tigris +// calls. type ClientOptions struct { BucketName string - S3Headers []func(*s3.Options) + S3Options []func(*s3.Options) // List options StartAfter *string MaxKeys *int32 } +// defaults populates client options from the global Options. func (ClientOptions) defaults(o Options) ClientOptions { return ClientOptions{ BucketName: o.BucketName, } } +// New creates a new Client based on the options provided and defaults loaded from the environment. +// +// By default New reads the following environment variables for setting its defaults: +// +// * `TIGRIS_STORAGE_BUCKET`: the name of the bucket for all Tigris operations. If this is not set in the environment or via the WithBucket, New() will return an error containing ErrNoBucketName. +// * `TIGRIS_STORAGE_ACCESS_KEY_ID`: The access key ID of the Tigris authentication keypair. If this is not set in the environment or via WithAccessKeypair, New() will load configuration via the AWS configuration resolution method. +// * `TIGRIS_STORAGE_SECRET_ACCESS_KEY`: The secret access key of the Tigris authentication keypair. If this is not set in the environment or via WithAccessKeypair, New() will load configuration via the AWS configuration resolution method. +// +// The returned Client will default to having its operations performed on the specified bucket. If +// individual calls need to operate against arbitrary buckets, override it with OverrideBucket. func New(ctx context.Context, options ...Option) (*Client, error) { o := new(Options).defaults() @@ -102,6 +125,10 @@ func New(ctx context.Context, options ...Option) (*Client, error) { }, nil } +// Object contains metadata about an individual object read from or put into Tigris. +// +// Some calls may not populate all fields. Ensure that the values are valid before +// consuming them. type Object struct { Bucket string // Bucket the object is in Key string // Key for the object @@ -113,6 +140,7 @@ type Object struct { Body io.ReadCloser // Body of the object so it can be read, don't forget to close it. } +// Get fetches the contents of an object and its metadata from Tigris. func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Object, error) { o := new(ClientOptions).defaults(c.options) @@ -126,7 +154,7 @@ func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Ob Bucket: aws.String(o.BucketName), Key: aws.String(key), }, - o.S3Headers..., + o.S3Options..., ) if err != nil { @@ -145,6 +173,7 @@ func (c *Client) Get(ctx context.Context, key string, opts ...ClientOption) (*Ob }, nil } +// Put puts the contents of an object into Tigris. func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*Object, error) { o := new(ClientOptions).defaults(c.options) @@ -161,7 +190,7 @@ func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*O ContentType: raise(obj.ContentType), ContentLength: raise(obj.Size), }, - o.S3Headers..., + o.S3Options..., ) if err != nil { @@ -175,6 +204,7 @@ func (c *Client) Put(ctx context.Context, obj *Object, opts ...ClientOption) (*O return obj, nil } +// Delete removes an object from Tigris. func (c *Client) Delete(ctx context.Context, key string, opts ...ClientOption) error { o := new(ClientOptions).defaults(c.options) @@ -188,7 +218,7 @@ func (c *Client) Delete(ctx context.Context, key string, opts ...ClientOption) e Bucket: aws.String(o.BucketName), Key: aws.String(key), }, - o.S3Headers..., + o.S3Options..., ); err != nil { return fmt.Errorf("simplestorage: can't delete %s/%s: %v", o.BucketName, key, err) } @@ -196,6 +226,7 @@ func (c *Client) Delete(ctx context.Context, key string, opts ...ClientOption) e return nil } +// List returns a list of objects matching a key prefix. func (c *Client) List(ctx context.Context, prefix string, opts ...ClientOption) ([]Object, error) { o := new(ClientOptions).defaults(c.options) @@ -212,7 +243,7 @@ func (c *Client) List(ctx context.Context, prefix string, opts ...ClientOption) MaxKeys: o.MaxKeys, StartAfter: o.StartAfter, }, - o.S3Headers..., + o.S3Options..., ) if err != nil { diff --git a/go/simplestorage/options.go b/go/simplestorage/options.go index 886d194..85f5549 100644 --- a/go/simplestorage/options.go +++ b/go/simplestorage/options.go @@ -6,16 +6,28 @@ import ( storage "github.com/tigrisdata/storage/go" ) +// Option is a functional option for new client creation. type Option func(o *Options) +// Options is the set of options for client creation. +// +// These fields are made public so you can implement your own configuration resolution methods. type Options struct { - BucketName string - AccessKeyID string + // The bucket to operate against. Defaults to the contents of the environment variable + // `TIGRIS_STORAGE_BUCKET`. + BucketName string + + // The access key ID of the Tigris keypair the Client should use. Defaults to the contents + // of the environment variable `TIGRIS_STORAGE_ACCESS_KEY_ID`. + AccessKeyID string + + // The access key ID of the Tigris keypair the Client should use. Defaults to the contents + // of the environment variable `TIGRIS_STORAGE_SECRET_ACCESS_KEY`. SecretAccessKey string - BaseEndpoint string - Region string - UsePathStyle bool + BaseEndpoint string // The Tigris base endpoint the Client should use (defaults to GlobalEndpoint) + Region string // The S3 region the Client should use (defaults to "auto"). + UsePathStyle bool // Should the Client use S3 path style resolution? (defaults to false). } func (Options) defaults() Options { @@ -30,6 +42,15 @@ func (Options) defaults() Options { } } +// WithBucket sets the default bucket for Tigris operations. If this is not set +// via the `TIGRIS_STORAGE_BUCKET` environment variable or this call, New() will +// return ErrNoBucketName. +func WithBucket(bucketName string) Option { + return func(o *Options) { + o.BucketName = bucketName + } +} + // WithFlyEndpoint lets you connect to Tigris' fly.io optimized endpoint. // // If you are deployed to fly.io, this zero-rates your traffic to Tigris.