From eaa2b9f1b0a4dbdcbd247aabe94fdd768cf49a4a Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:18:48 -0500 Subject: [PATCH 01/11] docs: add AI agent contribution guidelines Add comprehensive documentation for AI agents working on this project, including: - Conventional commits format and attribution requirements - Project structure overview for JavaScript/TypeScript and Go SDKs - Development workflows and testing guidelines - Release process information Assisted-by: GLM 4.7 via Claude Code --- AGENTS.md | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 130 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..775747f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,129 @@ +# Contribution Guidelines + +## Code Quality & Security + +### Commit Guidelines + +Commit messages follow **Conventional Commits** format: + +```text +[optional scope]: +[optional body] +[optional footer(s)] +``` + +**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` + +- Add `!` after type/scope for breaking changes or include `BREAKING CHANGE:` in the footer +- Keep descriptions concise, imperative, lowercase, and without a trailing period +- Reference issues/PRs in the footer when applicable + +### Attribution Requirements + +AI agents must disclose what tool and model they are using in the "Assisted-by" commit footer: + +```text +Assisted-by: [Model Name] via [Tool Name] +``` + +Example: + +```text +Assisted-by: GLM 4.6 via Claude Code +``` + +### Pull Request Requirements + +- Include a clear description of changes +- Reference any related issues +- Pass CI (`npm test` for JavaScript, `go test` for Go) +- Optionally add screenshots for UI changes + +### Security Best Practices + +- Secrets never belong in the repo; use environment variables or the `secrets` directory (ignored by Git) +- Run `npm audit` periodically for JavaScript packages and address reported vulnerabilities +- For Go, use `go mod` to manage dependencies and keep them updated + +## Project Structure + +This is a monorepo for Tigris object storage SDKs and CLI, containing: + +### JavaScript/TypeScript Packages + +Located in the root `packages/` directory as npm workspaces: + +- **`@tigrisdata/storage`** ([packages/storage](packages/storage)) - Tigris Storage SDK + - Built with TypeScript + - Uses AWS SDK v3 for S3 compatibility + - Exports both server and client modules + - Build: `npm run build:storage` + - Test: `npm run test --workspace=@tigrisdata/storage` + +- **`@tigrisdata/cli`** ([packages/cli](packages/cli)) - Command-line interface + - Built with TypeScript using Commander.js + - Depends on `@tigrisdata/storage` + - Build: `npm run build:cli` + +Root-level npm scripts: +- `npm run build` - Build all packages +- `npm test` - Run all tests +- `npm run lint` - Lint all packages +- `npm run format` - Format all packages with Prettier +- `npm run clean` - Clean build artifacts + +### Go SDK + +Located in the [`go/`](go/) directory: + +- **Module**: `github.com/tigrisdata/storage` +- **Go version**: 1.25.5 +- **Uses**: AWS SDK v2 for Go +- **Main files**: + - `client.go` - Client implementation + - `storage.go` - Storage operations + - `tigrisheaders/` - Tigris-specific headers + +Go commands: +- `go test ./go/...` - Run all Go tests +- `go build ./go/...` - Build all Go packages +- `go mod tidy` - Clean up dependencies + +## Development Workflow + +### JavaScript/TypeScript Development + +1. Install dependencies: `npm install` +2. Build packages: `npm run build` or `npm run build:storage` / `npm run build:cli` +3. Run tests: `npm test` +4. Format code: `npm run format` +5. Lint code: `npm run lint` + +### Go Development + +1. Navigate to Go directory: `cd go` +2. Run tests: `go test ./...` +3. Build: `go build ./...` +4. Format code: `go fmt ./...` +5. Manage dependencies: `go mod tidy` + +## Testing + +- **JavaScript**: Uses Vitest as the test runner +- **Go**: Uses the standard `go test` command +- Always run tests before committing changes +- Ensure all tests pass in CI before merging + +## Release Process + +- Releases are automated using semantic-release +- Commits to `main` trigger automatic releases +- Pre-releases are done on the `next` branch +- Both JavaScript packages and Go SDK follow semantic versioning + +## Additional Notes + +- The project uses Husky for Git hooks (commitlint, etc.) +- Commitizen is configured for conventional commits +- ESLint and Prettier are used for JavaScript/TypeScript code quality +- Go code should follow standard Go conventions and use `go fmt` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 2ad13f3ea585793efb5594a704f0180c045f2c52 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:20:39 -0500 Subject: [PATCH 02/11] refactor: rename test workflow to javascript workflow Rename .github/workflows/test.yaml to .github/workflows/javascript.yaml to better distinguish it from the Go workflow. Assisted-by: GLM 4.7 via Claude Code --- .github/workflows/{test.yaml => javascript.yaml} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename .github/workflows/{test.yaml => javascript.yaml} (92%) diff --git a/.github/workflows/test.yaml b/.github/workflows/javascript.yaml similarity index 92% rename from .github/workflows/test.yaml rename to .github/workflows/javascript.yaml index fdec437..2a9eec2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/javascript.yaml @@ -1,4 +1,4 @@ -name: Test +name: JavaScript tests on: pull_request: @@ -7,7 +7,7 @@ on: - next jobs: - test: + javascript_tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,7 +17,7 @@ jobs: # Set up Node.js - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - name: Install Dependencies run: npm ci From cb8adda3e183ecef86cac743bdc17384044cdf0c Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:21:20 -0500 Subject: [PATCH 03/11] feat: add Go SDK Add comprehensive Go SDK for Tigris object storage including: - S3-compatible client implementation using AWS SDK v2 - Storage operations (Put, Get, Delete, List, etc.) - Tigris-specific headers support - CI workflow for Go testing - Module documentation in README Assisted-by: GLM 4.7 via Claude Code --- .github/workflows/go.yaml | 46 +++ README.md | 4 + go.mod | 28 ++ go.sum | 38 ++ go/README.md | 244 +++++++++++ go/client.go | 89 ++++ go/example_test.go | 142 +++++++ go/storage.go | 106 +++++ go/storage_test.go | 320 +++++++++++++++ go/tigrisheaders/example_test.go | 115 ++++++ go/tigrisheaders/tigrisheaders.go | 150 +++++++ go/tigrisheaders/tigrisheaders_test.go | 540 +++++++++++++++++++++++++ 12 files changed, 1822 insertions(+) create mode 100644 .github/workflows/go.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 go/README.md create mode 100644 go/client.go create mode 100644 go/example_test.go create mode 100644 go/storage.go create mode 100644 go/storage_test.go create mode 100644 go/tigrisheaders/example_test.go create mode 100644 go/tigrisheaders/tigrisheaders.go create mode 100644 go/tigrisheaders/tigrisheaders_test.go diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..ebac06a --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,46 @@ +name: Go + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + actions: write + +jobs: + go_tests: + strategy: + matrix: + os: + - ubuntu-24.04 + - windows-2025 + - macos-15 + - ubuntu-24.04-arm + - windows-11-arm + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + fetch-tags: true + + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: latest + + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: stable + + - name: Test + run: | + go vet ./... + go test ./... + + - uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1.4.0 + with: + version: "latest" + install-go: false diff --git a/README.md b/README.md index 398cb4b..507833b 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,7 @@ This monorepo contains multiple packages for Tigris object storage: - [`@tigrisdata/storage`](./packages/storage) - Tigris Storage SDK - [`@tigrisdata/keyv-tigris`](./packages/keyv-tigris) - Tigris adapter for [Keyv](https://keyv.org/) - [`@tigrisdata/react`](./packages/react) - Ready to use React Components and Hooks + +## Go SDK + +For more information about the Go SDK, see the [Go SDK README](./go/README.md). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a58b63 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/tigrisdata/storage + +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 +) + +require ( + 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/smithy-go v1.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8b3905e --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +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= diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..d3cc030 --- /dev/null +++ b/go/README.md @@ -0,0 +1,244 @@ +# Tigris Storage SDK for Go + +Welcome to the Tigris Storage SDK for Go! This package contains high-level wrappers and helpers to help you take advantage of all of Tigris' features. + +## Overview + +[Tigris](https://www.tigrisdata.com/) is a cloud storage service that provides a simple, scalable, and secure object storage solution. It is based on the S3 API, but has additional features that need these helpers. + +This SDK provides two main packages: + +- **`storage`** - The main package containing the Tigris client with S3-compatible methods plus Tigris-specific features like bucket forking, snapshots, and object renaming. +- **`tigrisheaders`** - Lower-level helpers for setting Tigris-specific HTTP headers on S3 API calls. + +## Installation + +```bash +go get github.com/tigrisdata/storage/go +``` + +## Quick Start + +```go +package main + +import ( + "context" + "fmt" + "log" + + storage "github.com/tigrisdata/storage/go" +) + +func main() { + ctx := context.Background() + + // Create a new Tigris client + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // Use the client like you would use AWS S3 client + // plus Tigris-specific features (see below) + fmt.Println("Connected to Tigris!") +} +``` + +## Client Configuration + +The `New()` function creates a new S3 client optimized for interactions with Tigris. It accepts functional options to configure the client: + +```go +client, err := storage.New(ctx, + storage.WithFlyEndpoint(), // Use fly.io optimized endpoint + storage.WithGlobalEndpoint(), // Use globally available endpoint (default) + storage.WithRegion("auto"), // Specify a region + storage.WithAccessKeypair(key, secret), // Set access credentials +) +``` + +### Configuration Options + +| Option | Description | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `WithFlyEndpoint()` | Connect to Tigris' fly.io optimized endpoint. If you are deployed to fly.io, this zero-rates your traffic to Tigris. | +| `WithGlobalEndpoint()` | Connect to Tigris' globally available endpoint (`https://t3.storage.dev`). This is the default. | +| `WithRegion(region)` | Statically specify a region for interacting with Tigris. You will almost certainly never need this. | +| `WithAccessKeypair(accessKeyID, secretAccessKey)` | Specify a custom access key and secret access key. Useful when loading credentials from non-standard locations. | + +## Bucket Features + +### Snapshots and Forks + +Tigris supports bucket snapshots and forking, allowing you to create point-in-time copies of buckets and branch from them. + +#### Create a Snapshottable Bucket + +```go +output, err := client.CreateSnapshottableBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String("my-bucket"), +}) +``` + +#### Create a Snapshot + +```go +output, err := client.CreateBucketSnapshot(ctx, "Initial backup", &s3.CreateBucketInput{ + Bucket: aws.String("my-bucket"), +}) +``` + +#### Fork a Bucket + +```go +// Creates a new bucket "my-bucket-fork" as a fork of "my-bucket" +output, err := client.CreateBucketFork(ctx, "my-bucket", "my-bucket-fork") +``` + +#### List Snapshots + +```go +snapshots, err := client.ListBucketSnapshots(ctx, "my-bucket") +``` + +#### Get Fork/Snapshot Metadata + +```go +info, err := client.HeadBucketForkOrSnapshot(ctx, &s3.HeadBucketInput{ + Bucket: aws.String("my-bucket"), +}) +// info.SnapshotsEnabled - true if snapshots are enabled +// info.SourceBucket - The bucket this was forked from +// info.SourceBucketSnapshot - The snapshot this was forked from +// info.IsForkParent - true if there are forks of this bucket +``` + +## Object Features + +### Rename Objects + +Tigris supports in-place object renaming without copying data: + +```go +_, err := client.RenameObject(ctx, &s3.CopyObjectInput{ + Bucket: aws.String("my-bucket"), + CopySource: aws.String("my-bucket/old-name.txt"), + Key: aws.String("new-name.txt"), +}) +``` + +## Using the tigrisheaders Package + +The `tigrisheaders` package provides lower-level helpers for setting Tigris-specific HTTP headers. These can be used directly with S3 client operations. + +### Static Replication Regions + +Control which regions your objects are replicated to: + +```go +import "github.com/tigrisdata/storage/go/tigrisheaders" + +// Replicate to specific regions +_, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String("my-bucket"), + Key: aws.String("file.txt"), + Body: bytes.NewReader(data), +}, tigrisheaders.WithStaticReplicationRegions([]tigrisheaders.Region{ + tigrisheaders.FRA, // Frankfurt + tigrisheaders.SJC, // San Jose +})) +``` + +Available regions: + +- `FRA` - Frankfurt, Germany +- `GRU` - São Paulo, Brazil +- `HKG` - Hong Kong, China +- `IAD` - Ashburn, Virginia, USA +- `JNB` - Johannesburg, South Africa +- `LHR` - London, UK +- `MAD` - Madrid, Spain +- `NRT` - Tokyo (Narita), Japan +- `ORD` - Chicago, Illinois, USA +- `SIN` - Singapore +- `SJC` - San Jose, California, USA +- `SYD` - Sydney, Australia +- `Europe` - European datacenters +- `USA` - American datacenters + +### Query Metadata + +Filter objects in a ListObjectsV2 request with a SQL-like WHERE clause: + +```go +_, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("my-bucket"), +}, tigrisheaders.WithQuery("metadata.user_id = '123'")) +``` + +### Conditional Operations + +Perform operations based on object state: + +```go +// Create object only if it doesn't exist +_, err := client.PutObject(ctx, input, + tigrisheaders.WithCreateObjectIfNotExists()) + +// Only proceed if ETag matches +_, err := client.PutObject(ctx, input, + tigrisheaders.WithIfEtagMatches("\"abc123\"")) + +// Only proceed if modified since date +_, err := client.GetObject(ctx, input, + tigrisheaders.WithModifiedSince(time.Now().Add(-24 * time.Hour))) + +// Only proceed if unmodified since date +_, err := client.GetObject(ctx, input, + tigrisheaders.WithUnmodifiedSince(time.Now().Add(-24 * time.Hour))) + +// Compare-and-swap (skip cache, read from designated region) +_, err := client.GetObject(ctx, input, + tigrisheaders.WithCompareAndSwap()) +``` + +### Snapshot Operations + +Work with specific snapshot versions: + +```go +// List objects from a specific snapshot +_, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("my-bucket"), +}, tigrisheaders.WithSnapshotVersion("snapshot-id")) + +// Get object from a specific snapshot +_, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String("my-bucket"), + Key: aws.String("file.txt"), +}, tigrisheaders.WithSnapshotVersion("snapshot-id")) +``` + +### Custom Headers + +Set arbitrary HTTP headers on requests: + +```go +_, err := client.PutObject(ctx, input, + tigrisheaders.WithHeader("X-Custom-Header", "value")) +``` + +## Documentation + +For more information on Tigris features, see: + +- [Snapshots and Forks](https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/) +- [Object Rename](https://www.tigrisdata.com/docs/objects/object-rename/) +- [Query Metadata](https://www.tigrisdata.com/docs/objects/query-metadata/) +- [Conditional Operations](https://www.tigrisdata.com/docs/objects/conditionals/) +- [Regions](https://www.tigrisdata.com/docs/concepts/regions/) + +## License + +See [LICENSE](../LICENSE) for details. diff --git a/go/client.go b/go/client.go new file mode 100644 index 0000000..ca6279e --- /dev/null +++ b/go/client.go @@ -0,0 +1,89 @@ +package storage + +import ( + "context" + "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/tigrisdata/storage/go/tigrisheaders" +) + +// Client is a wrapper around the AWS SDK S3 Client with additional methods for integration with Tigris. +type Client struct { + *s3.Client +} + +// CreateBucketFork creates a fork of the source bucket named target. +func (c *Client) CreateBucketFork(ctx context.Context, source, target string, opts ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { + opts = append(opts, tigrisheaders.WithHeader("X-Tigris-Fork-Source-Bucket", source)) + + return c.Client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(target), + }, opts...) +} + +// 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) { + opts = append(opts, tigrisheaders.WithTakeSnapshot(description)) + + 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) { + opts = append(opts, tigrisheaders.WithEnableSnapshot()) + + return c.Client.CreateBucket(ctx, in, opts...) +} + +// HeadBucketForkOrSnapshotOutput records the fork/snapshot metadata for a bucket. +type HeadBucketForkOrSnapshotOutput struct { + SnapshotsEnabled bool // true if snapshots are enabled, otherwise false. + SourceBucket string // The name of the bucket this bucket was forked from. + SourceBucketSnapshot string // The snapshot this bucket was forked from. + IsForkParent bool // true if there are forks of this bucket, otherwise false. +} + +// HeadBucketForkOrSnapshot fetches the fork/snapshot metadata for a bucket. +// +// For more information, see the Tigris documentation[1]. +// +// [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#retrieving-snapshot-and-fork-info-for-a-bucket +func (c *Client) HeadBucketForkOrSnapshot(ctx context.Context, in *s3.HeadBucketInput, opts ...func(*s3.Options)) (*HeadBucketForkOrSnapshotOutput, error) { + resp, err := c.Client.HeadBucket(ctx, in, opts...) + if err != nil { + return nil, err + } + + rawResp := middleware.GetRawResponse(resp.ResultMetadata).(*http.Response) + return &HeadBucketForkOrSnapshotOutput{ + SnapshotsEnabled: rawResp.Header.Get("X-Tigris-Enable-Snapshot") == "true", + SourceBucket: rawResp.Header.Get("X-Tigris-Fork-Source-Bucket"), + SourceBucketSnapshot: rawResp.Header.Get("X-Tigris-Fork-Source-Bucket-Snapshot"), + IsForkParent: rawResp.Header.Get("X-Tigris-Is-Fork-Parent") == "true", + }, nil +} + +// ListBucketSnapshots lists the snapshots for a bucket. +// +// For more information, see the Tigris documentation[1]. +// +// [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#listing-snapshots +func (c *Client) ListBucketSnapshots(ctx context.Context, bucketName string, opts ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { + opts = append(opts, tigrisheaders.WithHeader("X-Tigris-Snapshot", bucketName)) + + return c.Client.ListBuckets(ctx, &s3.ListBucketsInput{}, opts...) +} + +// RenameObject performs an in-place rename of objects instead of copying the data. +// +// For more information, see the Tigris documentation[1]. +// +// [1]: https://www.tigrisdata.com/docs/objects/object-rename/ +func (c *Client) RenameObject(ctx context.Context, in *s3.CopyObjectInput, opts ...func(*s3.Options)) (*s3.CopyObjectOutput, error) { + opts = append(opts, tigrisheaders.WithRename()) + + return c.Client.CopyObject(ctx, in, opts...) +} diff --git a/go/example_test.go b/go/example_test.go new file mode 100644 index 0000000..a76c4a5 --- /dev/null +++ b/go/example_test.go @@ -0,0 +1,142 @@ +package storage_test + +import ( + "context" + "log" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + storage "github.com/tigrisdata/storage/go" +) + +func ExampleNew() { + ctx := context.Background() + + // Create a new Tigris client with default options + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + _ = client + + // Create a client with custom options + client, err = storage.New(ctx, + storage.WithFlyEndpoint(), // Use fly.io optimized endpoint + storage.WithGlobalEndpoint(), // Use globally available endpoint (default) + storage.WithRegion("auto"), // Specify a region + // storage.WithAccessKeypair(key, secret), // Set access credentials + ) + if err != nil { + log.Fatal(err) + } + _ = client +} + +func ExampleClient_CreateSnapshottableBucket() { + ctx := context.Background() + + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // Create a bucket with snapshot support enabled + output, err := client.CreateSnapshottableBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String("my-bucket"), + }) + if err != nil { + log.Fatal(err) + } + _ = output +} + +func ExampleClient_CreateBucketSnapshot() { + ctx := context.Background() + + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // Create a snapshot with a description + output, err := client.CreateBucketSnapshot(ctx, "Initial backup", &s3.CreateBucketInput{ + Bucket: aws.String("my-bucket"), + }) + if err != nil { + log.Fatal(err) + } + _ = output +} + +func ExampleClient_CreateBucketFork() { + ctx := context.Background() + + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // Creates a new bucket "my-bucket-fork" as a fork of "my-bucket" + output, err := client.CreateBucketFork(ctx, "my-bucket", "my-bucket-fork") + if err != nil { + log.Fatal(err) + } + _ = output +} + +func ExampleClient_ListBucketSnapshots() { + ctx := context.Background() + + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // List all snapshots for a bucket + snapshots, err := client.ListBucketSnapshots(ctx, "my-bucket") + if err != nil { + log.Fatal(err) + } + _ = snapshots +} + +func ExampleClient_HeadBucketForkOrSnapshot() { + ctx := context.Background() + + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // Get fork/snapshot metadata for a bucket + info, err := client.HeadBucketForkOrSnapshot(ctx, &s3.HeadBucketInput{ + Bucket: aws.String("my-bucket"), + }) + if err != nil { + log.Fatal(err) + } + + _ = info.SnapshotsEnabled // true if snapshots are enabled + _ = info.SourceBucket // The bucket this was forked from + _ = info.SourceBucketSnapshot // The snapshot this was forked from + _ = info.IsForkParent // true if there are forks of this bucket +} + +func ExampleClient_RenameObject() { + ctx := context.Background() + + client, err := storage.New(ctx) + if err != nil { + log.Fatal(err) + } + + // Rename an object in-place without copying data + _, err = client.RenameObject(ctx, &s3.CopyObjectInput{ + Bucket: aws.String("my-bucket"), + CopySource: aws.String("my-bucket/old-name.txt"), + Key: aws.String("new-name.txt"), + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/go/storage.go b/go/storage.go new file mode 100644 index 0000000..d296bd2 --- /dev/null +++ b/go/storage.go @@ -0,0 +1,106 @@ +// Package tigrissdk contains a Tigris client and helpers for interacting with Tigris. +// +// Tigris is a cloud storage service that provides a simple, scalable, and secure object storage solution. It is based on the S3 API, but has additional features that need these helpers. +package storage + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Options is the set of options that can be configured for the Tigris client. +type Options struct { + BaseEndpoint string + Region string + UsePathStyle bool + + AccessKeyID string + SecretAccessKey string +} + +// 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, + } +} + +// Option is a functional option for configuring the Tigris client. +type Option func(o *Options) + +// 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 = "https://fly.storage.tigris.dev" + } +} + +// 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 = "https://t3.storage.dev" + } +} + +// 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 + } +} + +// 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 + } +} + +// New returns a new S3 client optimized for interactions with Tigris. +func New(ctx context.Context, options ...Option) (*Client, error) { + o := new(Options).defaults() + + for _, doer := range options { + doer(&o) + } + + var creds aws.CredentialsProvider + + if o.AccessKeyID != "" && o.SecretAccessKey != "" { + creds = credentials.NewStaticCredentialsProvider(o.AccessKeyID, o.SecretAccessKey, "") + } + + cfg, err := awsConfig.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load Tigris config: %w", err) + } + + cli := s3.NewFromConfig(cfg, func(opts *s3.Options) { + opts.BaseEndpoint = aws.String(o.BaseEndpoint) + opts.Region = o.Region + opts.UsePathStyle = o.UsePathStyle + opts.Credentials = creds + }) + + return &Client{ + Client: cli, + }, nil +} diff --git a/go/storage_test.go b/go/storage_test.go new file mode 100644 index 0000000..c5a4ce6 --- /dev/null +++ b/go/storage_test.go @@ -0,0 +1,320 @@ +package storage + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +func TestOptions_defaults(t *testing.T) { + tests := []struct { + name string + input Options + wantEndpoint string + wantRegion string + wantPathStyle bool + }{ + { + name: "default values", + input: Options{}, + wantEndpoint: "https://t3.storage.dev", + wantRegion: "auto", + wantPathStyle: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.input.defaults() + if got.BaseEndpoint != tt.wantEndpoint { + t.Errorf("BaseEndpoint = %v, want %v", got.BaseEndpoint, tt.wantEndpoint) + } + if got.Region != tt.wantRegion { + t.Errorf("Region = %v, want %v", got.Region, tt.wantRegion) + } + if got.UsePathStyle != tt.wantPathStyle { + t.Errorf("UsePathStyle = %v, want %v", got.UsePathStyle, tt.wantPathStyle) + } + }) + } +} + +func TestWithFlyEndpoint(t *testing.T) { + o := &Options{} + WithFlyEndpoint()(o) + + if o.BaseEndpoint != "https://fly.storage.tigris.dev" { + t.Errorf("BaseEndpoint = %v, want https://fly.storage.tigris.dev", o.BaseEndpoint) + } +} + +func TestWithGlobalEndpoint(t *testing.T) { + o := &Options{} + WithGlobalEndpoint()(o) + + if o.BaseEndpoint != "https://t3.storage.dev" { + t.Errorf("BaseEndpoint = %v, want https://t3.storage.dev", o.BaseEndpoint) + } +} + +func TestWithRegion(t *testing.T) { + tests := []struct { + name string + region string + }{ + {"auto region", "auto"}, + {"us-west-2", "us-west-2"}, + {"eu-central-1", "eu-central-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &Options{} + WithRegion(tt.region)(o) + + if o.Region != tt.region { + t.Errorf("Region = %v, want %v", o.Region, tt.region) + } + }) + } +} + +func TestWithAccessKeypair(t *testing.T) { + o := &Options{} + accessKeyID := "test-access-key" + secretAccessKey := "test-secret-key" + + WithAccessKeypair(accessKeyID, secretAccessKey)(o) + + if o.AccessKeyID != accessKeyID { + t.Errorf("AccessKeyID = %v, want %v", o.AccessKeyID, accessKeyID) + } + if o.SecretAccessKey != secretAccessKey { + t.Errorf("SecretAccessKey = %v, want %v", o.SecretAccessKey, secretAccessKey) + } +} + +func TestWithAccessKeypair_overrides(t *testing.T) { + o := &Options{ + AccessKeyID: "old-key", + SecretAccessKey: "old-secret", + } + + WithAccessKeypair("new-key", "new-secret")(o) + + if o.AccessKeyID != "new-key" { + t.Errorf("AccessKeyID = %v, want new-key", o.AccessKeyID) + } + if o.SecretAccessKey != "new-secret" { + t.Errorf("SecretAccessKey = %v, want new-secret", o.SecretAccessKey) + } +} + +func TestOptions_functionalOptions(t *testing.T) { + tests := []struct { + name string + options []Option + want Options + }{ + { + name: "no options uses defaults", + options: nil, + want: Options{ + BaseEndpoint: "https://t3.storage.dev", + Region: "auto", + UsePathStyle: false, + }, + }, + { + name: "fly endpoint", + options: []Option{ + WithFlyEndpoint(), + }, + want: Options{ + BaseEndpoint: "https://fly.storage.tigris.dev", + Region: "auto", + UsePathStyle: false, + }, + }, + { + name: "global endpoint (explicit)", + options: []Option{ + WithGlobalEndpoint(), + }, + want: Options{ + BaseEndpoint: "https://t3.storage.dev", + Region: "auto", + UsePathStyle: false, + }, + }, + { + name: "custom region", + options: []Option{ + WithRegion("us-west-2"), + }, + want: Options{ + BaseEndpoint: "https://t3.storage.dev", + Region: "us-west-2", + UsePathStyle: false, + }, + }, + { + name: "with credentials", + options: []Option{ + WithAccessKeypair("key-id", "secret"), + }, + want: Options{ + BaseEndpoint: "https://t3.storage.dev", + Region: "auto", + UsePathStyle: false, + AccessKeyID: "key-id", + SecretAccessKey: "secret", + }, + }, + { + name: "multiple options", + options: []Option{ + WithFlyEndpoint(), + WithRegion("eu-central-1"), + WithAccessKeypair("key", "secret"), + }, + want: Options{ + BaseEndpoint: "https://fly.storage.tigris.dev", + Region: "eu-central-1", + UsePathStyle: false, + AccessKeyID: "key", + SecretAccessKey: "secret", + }, + }, + { + name: "last option wins for endpoint", + options: []Option{ + WithFlyEndpoint(), + WithGlobalEndpoint(), + }, + want: Options{ + BaseEndpoint: "https://t3.storage.dev", + Region: "auto", + UsePathStyle: false, + }, + }, + { + name: "last option wins for region", + options: []Option{ + WithRegion("us-west-2"), + WithRegion("eu-central-1"), + }, + want: Options{ + BaseEndpoint: "https://t3.storage.dev", + Region: "eu-central-1", + UsePathStyle: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := new(Options).defaults() + + for _, opt := range tt.options { + opt(&o) + } + + if o.BaseEndpoint != tt.want.BaseEndpoint { + t.Errorf("BaseEndpoint = %v, want %v", o.BaseEndpoint, tt.want.BaseEndpoint) + } + if o.Region != tt.want.Region { + t.Errorf("Region = %v, want %v", o.Region, tt.want.Region) + } + if o.UsePathStyle != tt.want.UsePathStyle { + t.Errorf("UsePathStyle = %v, want %v", o.UsePathStyle, tt.want.UsePathStyle) + } + if o.AccessKeyID != tt.want.AccessKeyID { + t.Errorf("AccessKeyID = %v, want %v", o.AccessKeyID, tt.want.AccessKeyID) + } + if o.SecretAccessKey != tt.want.SecretAccessKey { + t.Errorf("SecretAccessKey = %v, want %v", o.SecretAccessKey, tt.want.SecretAccessKey) + } + }) + } +} + +func TestNew_createsClient(t *testing.T) { + // This test verifies that New() creates a valid client structure + // It doesn't actually connect to Tigris, just checks initialization + + ctx := context.Background() + + t.Run("with default options", func(t *testing.T) { + client, err := New(ctx) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if client == nil { + t.Fatal("New() returned nil client") + } + if client.Client == nil { + t.Fatal("New() returned client with nil S3 client") + } + }) + + t.Run("with fly endpoint", func(t *testing.T) { + client, err := New(ctx, WithFlyEndpoint()) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if client == nil { + t.Fatal("New() returned nil client") + } + }) +} + +func TestNew_withOptions(t *testing.T) { + ctx := context.Background() + + t.Run("with custom region", func(t *testing.T) { + client, err := New(ctx, WithRegion("us-west-2")) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if client == nil { + t.Fatal("New() returned nil client") + } + if client.Client == nil { + t.Fatal("New() returned client with nil S3 client") + } + }) + + t.Run("with credentials", func(t *testing.T) { + client, err := New(ctx, WithAccessKeypair("test-key", "test-secret")) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if client == nil { + t.Fatal("New() returned nil client") + } + if client.Client == nil { + t.Fatal("New() returned client with nil S3 client") + } + }) +} + +// MockS3Client is a mock implementation for testing +type MockS3Client struct { + *s3.Client +} + +func (m *MockS3Client) Close() error { + return nil +} + +// Test that Client wraps the S3 client correctly +func TestClient_wrapsS3Client(t *testing.T) { + s3Client := &s3.Client{} + client := &Client{Client: s3Client} + + if client.Client != s3Client { + t.Error("Client.Client is not the provided S3 client") + } +} \ No newline at end of file diff --git a/go/tigrisheaders/example_test.go b/go/tigrisheaders/example_test.go new file mode 100644 index 0000000..96f2f14 --- /dev/null +++ b/go/tigrisheaders/example_test.go @@ -0,0 +1,115 @@ +package tigrisheaders_test + +import ( + "bytes" + "context" + "log" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + storage "github.com/tigrisdata/storage/go" + "github.com/tigrisdata/storage/go/tigrisheaders" +) + +var client *storage.Client +var ctx context.Context +var data []byte + +func ExampleWithStaticReplicationRegions() { + // Replicate to specific regions + _, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String("my-bucket"), + Key: aws.String("file.txt"), + Body: bytes.NewReader(data), + }, tigrisheaders.WithStaticReplicationRegions([]tigrisheaders.Region{ + tigrisheaders.FRA, // Frankfurt + tigrisheaders.SJC, // San Jose + })) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithQuery() { + // Filter objects with a SQL-like WHERE clause + _, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("my-bucket"), + }, tigrisheaders.WithQuery("WHERE `Last-Modified` > \"2023-01-15T08:30:00Z\" AND `Content-Type` = \"text/javascript\"")) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithCreateObjectIfNotExists() { + // Create object only if it doesn't exist + _, err := client.PutObject(ctx, &s3.PutObjectInput{}, + tigrisheaders.WithCreateObjectIfNotExists()) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithIfEtagMatches() { + // Only proceed if ETag matches + _, err := client.PutObject(ctx, &s3.PutObjectInput{}, + tigrisheaders.WithIfEtagMatches(`"abc123"`)) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithModifiedSince() { + // Only proceed if modified since date + _, err := client.GetObject(ctx, &s3.GetObjectInput{}, + tigrisheaders.WithModifiedSince(time.Now().Add(-24*time.Hour))) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithUnmodifiedSince() { + // Only proceed if unmodified since date + _, err := client.GetObject(ctx, &s3.GetObjectInput{}, + tigrisheaders.WithUnmodifiedSince(time.Now().Add(-24*time.Hour))) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithCompareAndSwap() { + // Compare-and-swap (skip cache, read from designated region) + _, err := client.GetObject(ctx, &s3.GetObjectInput{}, + tigrisheaders.WithCompareAndSwap()) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithSnapshotVersion() { + // List objects from a specific snapshot + _, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String("my-bucket"), + }, tigrisheaders.WithSnapshotVersion("snapshot-id")) + if err != nil { + log.Fatal(err) + } + + // Get object from a specific snapshot + _, err = client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String("my-bucket"), + Key: aws.String("file.txt"), + }, tigrisheaders.WithSnapshotVersion("snapshot-id")) + if err != nil { + log.Fatal(err) + } +} + +func ExampleWithHeader() { + // Set arbitrary HTTP header on request + _, err := client.PutObject(ctx, &s3.PutObjectInput{}, + tigrisheaders.WithHeader("X-Custom-Header", "value")) + if err != nil { + log.Fatal(err) + } +} diff --git a/go/tigrisheaders/tigrisheaders.go b/go/tigrisheaders/tigrisheaders.go new file mode 100644 index 0000000..6e15b92 --- /dev/null +++ b/go/tigrisheaders/tigrisheaders.go @@ -0,0 +1,150 @@ +// Package tigrisheaders contains Tigris-specific header helpers for the AWS S3 SDK and helpers for interacting with Tigris. +// +// Tigris is a cloud storage service that provides a simple, scalable, and secure object storage solution. It is based on the S3 API, but has additional features that need these helpers. +package tigrisheaders + +import ( + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go/transport/http" +) + +// WithHeader sets an arbitrary HTTP header on the request. +func WithHeader(key, value string) func(*s3.Options) { + return func(options *s3.Options) { + options.APIOptions = append(options.APIOptions, http.AddHeaderValue(key, value)) + } +} + +// Region is a Tigris region from the documentation. +// +// https://www.tigrisdata.com/docs/concepts/regions/ +type Region string + +// Possible Tigris regions. +const ( + FRA Region = "fra" // Frankfurt, Germany + GRU Region = "gru" // São Paulo, Brazil + HKG Region = "hkg" // Hong Kong, China + IAD Region = "iad" // Ashburn, Virginia, USA + JNB Region = "jnb" // Johannesburg, South Africa + LHR Region = "lhr" // London, UK + MAD Region = "mad" // Madrid, Spain + NRT Region = "nrt" // Tokyo (Narita), Japan + ORD Region = "ord" // Chicago, Illinois, USA + SIN Region = "sin" // Singapore + SJC Region = "sjc" // San Jose, California, USA + SYD Region = "syd" // Sydney, Australia + + Europe Region = "eur" // European datacenters + USA Region = "usa" // American datacenters +) + +// WithStaticReplicationRegions sets the regions where the object will be replicated. +// +// Note that this will cause you to be charged multiple times for the same object, once per region. +func WithStaticReplicationRegions(regions []Region) func(*s3.Options) { + regionsString := make([]string, 0, len(regions)) + for _, r := range regions { + regionsString = append(regionsString, string(r)) + } + + return WithHeader("X-Tigris-Regions", strings.Join(regionsString, ",")) +} + +// WithQuery lets you filter objects in a ListObjectsV2 request. +// +// This functions like the WHERE clause in SQL, but for S3 objects. For more information, see the Tigris documentation[1]. +// +// [1]: https://www.tigrisdata.com/docs/objects/query-metadata/ +func WithQuery(query string) func(*s3.Options) { + return WithHeader("X-Tigris-Query", query) +} + +// WithCreateIfNotExists will create the object if it doesn't exist. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/objects/conditionals/ +func WithCreateObjectIfNotExists() func(*s3.Options) { + return WithHeader("If-Match", `""`) +} + +// WithIfEtagMatches sets the ETag that the object must match. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/objects/conditionals/ +func WithIfEtagMatches(etag string) func(*s3.Options) { + return WithHeader("If-Match", etag) +} + +// WithModifiedSince lets you proceed with operation if object was modified after provided date (RFC1123). +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/objects/conditionals/ +func WithModifiedSince(modifiedSince time.Time) func(*s3.Options) { + return WithHeader("If-Modified-Since", modifiedSince.Format(time.RFC1123)) +} + +// WithUnmodifiedSince lets you proceed with operation if object was not modified after provided date (RFC1123). +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/objects/conditionals/ +func WithUnmodifiedSince(unmodifiedSince time.Time) func(*s3.Options) { + return WithHeader("If-Unmodified-Since", unmodifiedSince.Format(time.RFC1123)) +} + +// WithCompareAndSwap tells Tigris to skip the cache and read the object from its designated region. +// +// This is only used on GET requests. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/objects/conditionals/ +func WithCompareAndSwap() func(*s3.Options) { + return WithHeader("X-Tigris-CAS", "true") +} + +// WithEnableSnapshot tells Tigris to enable bucket snapshotting when creating buckets. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#enabling-snapshots-and-forks +func WithEnableSnapshot() func(*s3.Options) { + return WithHeader("X-Tigris-Enable-Snapshot", "true") +} + +// WithTakeSnapshot tells Tigris to create a snapshot with the given description on a forkable bucket. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#creating-a-snapshot +func WithTakeSnapshot(desc string) func(*s3.Options) { + return WithHeader("X-Tigris-Snapshot", fmt.Sprintf("true; desc=%s", desc)) +} + +// WithSnapshotVersion tells Tigris to use a given snapshot when doing ListObjectsV2, GetObject, or HeadObject calls. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#listing-and-retrieving-objects-from-a-snapshot +func WithSnapshotVersion(snapshotVersion string) func(*s3.Options) { + return WithHeader("X-Tigris-Snapshot-Version", snapshotVersion) +} + +// WithRename tells Tigris to do an in-place rename of objects instead of copying them when using a CopyObject call. +// +// See the Tigris documentation[1] for more information. +// +// [1]: https://www.tigrisdata.com/docs/objects/object-rename/#renaming-objects-using-aws-sdks +func WithRename() func(*s3.Options) { + return func(options *s3.Options) { + options.APIOptions = append(options.APIOptions, http.AddHeaderValue("X-Tigris-Rename", "true")) + } +} diff --git a/go/tigrisheaders/tigrisheaders_test.go b/go/tigrisheaders/tigrisheaders_test.go new file mode 100644 index 0000000..ff3fdfe --- /dev/null +++ b/go/tigrisheaders/tigrisheaders_test.go @@ -0,0 +1,540 @@ +package tigrisheaders + +import ( + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Test that the header functions return valid option functions +func TestHeaderFunctionsAreValid(t *testing.T) { + tests := []struct { + name string + apply func(*s3.Options) + }{ + {"WithHeader", func(o *s3.Options) { WithHeader("X-Test", "value")(o) }}, + {"WithStaticReplicationRegions", func(o *s3.Options) { WithStaticReplicationRegions([]Region{FRA, SJC})(o) }}, + {"WithQuery", func(o *s3.Options) { WithQuery("WHERE key = 'value'")(o) }}, + {"WithCreateObjectIfNotExists", func(o *s3.Options) { WithCreateObjectIfNotExists()(o) }}, + {"WithIfEtagMatches", func(o *s3.Options) { WithIfEtagMatches(`"abc"`)(o) }}, + {"WithModifiedSince", func(o *s3.Options) { WithModifiedSince(time.Now())(o) }}, + {"WithUnmodifiedSince", func(o *s3.Options) { WithUnmodifiedSince(time.Now())(o) }}, + {"WithCompareAndSwap", func(o *s3.Options) { WithCompareAndSwap()(o) }}, + {"WithEnableSnapshot", func(o *s3.Options) { WithEnableSnapshot()(o) }}, + {"WithTakeSnapshot", func(o *s3.Options) { WithTakeSnapshot("test")(o) }}, + {"WithSnapshotVersion", func(o *s3.Options) { WithSnapshotVersion("v1")(o) }}, + {"WithRename", func(o *s3.Options) { WithRename()(o) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + // Should not panic + tt.apply(opts) + // Should add to APIOptions + if len(opts.APIOptions) == 0 { + t.Errorf("%s() did not add any APIOptions", tt.name) + } + }) + } +} + +func TestWithStaticReplicationRegions_formatting(t *testing.T) { + tests := []struct { + name string + regions []Region + want string + }{ + { + name: "single region", + regions: []Region{FRA}, + want: "fra", + }, + { + name: "multiple regions", + regions: []Region{FRA, SJC, LHR}, + want: "fra,sjc,lhr", + }, + { + name: "all specific regions", + regions: []Region{FRA, GRU, HKG, IAD, JNB, LHR, MAD, NRT, ORD, SIN, SJC, SYD}, + want: "fra,gru,hkg,iad,jnb,lhr,mad,nrt,ord,sin,sjc,syd", + }, + { + name: "aggregate regions", + regions: []Region{Europe, USA}, + want: "eur,usa", + }, + { + name: "mixed aggregate and specific", + regions: []Region{FRA, Europe, SJC, USA}, + want: "fra,eur,sjc,usa", + }, + { + name: "empty regions", + regions: []Region{}, + want: "", + }, + { + name: "single aggregate", + regions: []Region{Europe}, + want: "eur", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithStaticReplicationRegions(tt.regions)(opts) + + if len(opts.APIOptions) == 0 { + t.Fatal("WithStaticReplicationRegions() did not add any APIOptions") + } + }) + } +} + +func TestWithQuery_variousInputs(t *testing.T) { + tests := []struct { + name string + query string + }{ + { + name: "simple query", + query: "WHERE `key` = 'value'", + }, + { + name: "complex query", + query: "WHERE `Last-Modified` > \"2023-01-15T08:30:00Z\" AND `Content-Type` = \"text/javascript\"", + }, + { + name: "empty query", + query: "", + }, + { + name: "query with special characters", + query: "WHERE `name` LIKE '%test%' AND `size` > 1024", + }, + { + name: "query with newlines", + query: "WHERE `key` = 'value'\nAND `other` > 5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithQuery(tt.query)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithQuery() did not add any APIOptions") + } + }) + } +} + +func TestWithIfEtagMatches_variousInputs(t *testing.T) { + tests := []struct { + name string + etag string + }{ + {"simple etag", `"abc123"`}, + {"quoted etag", `"d41d8cd98f00b204e9800998ecf8427e"`}, + {"empty quotes", `""`}, + {"etag with hyphens", `"abc-123-def"`}, + {"etag with numbers", `"123456789"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithIfEtagMatches(tt.etag)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithIfEtagMatches() did not add any APIOptions") + } + }) + } +} + +func TestWithModifiedSince_formats(t *testing.T) { + times := []struct { + name string + t time.Time + }{ + { + name: "2023-01-01", + t: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "2023-12-31 end of day", + t: time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC), + }, + { + name: "2024-06-15 midday", + t: time.Date(2024, 6, 15, 12, 30, 45, 0, time.UTC), + }, + { + name: "unix epoch", + t: time.Unix(0, 0).UTC(), + }, + { + name: "with nanoseconds", + t: time.Date(2023, 5, 15, 10, 30, 0, 123456789, time.UTC), + }, + } + + for _, tt := range times { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithModifiedSince(tt.t)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithModifiedSince() did not add any APIOptions") + } + }) + } +} + +func TestWithUnmodifiedSince_formats(t *testing.T) { + times := []struct { + name string + t time.Time + }{ + { + name: "2023-01-01", + t: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "2023-12-31 end of day", + t: time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC), + }, + { + name: "unix epoch", + t: time.Unix(0, 0).UTC(), + }, + } + + for _, tt := range times { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithUnmodifiedSince(tt.t)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithUnmodifiedSince() did not add any APIOptions") + } + }) + } +} + +func TestWithTakeSnapshot_variousDescriptions(t *testing.T) { + tests := []struct { + name string + description string + }{ + { + name: "simple description", + description: "Initial backup", + }, + { + name: "description with spaces", + description: "Backup before migration", + }, + { + name: "description with special chars", + description: "Backup: v1.0.0 (final)", + }, + { + name: "empty description", + description: "", + }, + { + name: "unicode description", + description: "备份 before deployment", + }, + { + name: "description with semicolons", + description: "backup;version;1.0", + }, + { + name: "description with equals", + description: "desc=test backup", + }, + { + name: "very long description", + description: strings.Repeat("a", 500), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithTakeSnapshot(tt.description)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithTakeSnapshot() did not add any APIOptions") + } + }) + } +} + +func TestWithSnapshotVersion_variousInputs(t *testing.T) { + tests := []struct { + name string + version string + }{ + {"simple version", "snapshot-id"}, + {"uuid-like version", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}, + {"numeric version", "12345"}, + {"version with hyphens", "snap-2023-01-15"}, + {"empty version", ""}, + {"version with underscores", "snapshot_v1_0"}, + {"version with dots", "v1.0.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithSnapshotVersion(tt.version)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithSnapshotVersion() did not add any APIOptions") + } + }) + } +} + +func TestRegionConstants(t *testing.T) { + tests := []struct { + region Region + want string + }{ + {FRA, "fra"}, + {GRU, "gru"}, + {HKG, "hkg"}, + {IAD, "iad"}, + {JNB, "jnb"}, + {LHR, "lhr"}, + {MAD, "mad"}, + {NRT, "nrt"}, + {ORD, "ord"}, + {SIN, "sin"}, + {SJC, "sjc"}, + {SYD, "syd"}, + {Europe, "eur"}, + {USA, "usa"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if string(tt.region) != tt.want { + t.Errorf("Region %s = %q, want %q", tt.region, tt.region, tt.want) + } + }) + } +} + +// Test that multiple header functions can be composed +func TestMultipleHeadersCanBeComposed(t *testing.T) { + opts := &s3.Options{} + + initialCount := len(opts.APIOptions) + + WithHeader("X-Header-1", "value1")(opts) + WithHeader("X-Header-2", "value2")(opts) + WithStaticReplicationRegions([]Region{FRA, SJC})(opts) + WithCompareAndSwap()(opts) + + if len(opts.APIOptions) <= initialCount { + t.Error("Multiple header functions did not add APIOptions") + } + + // Each call should add one APIOption + expectedAdded := 4 + if len(opts.APIOptions)-initialCount != expectedAdded { + t.Errorf("Expected %d APIOptions to be added, got %d", expectedAdded, len(opts.APIOptions)-initialCount) + } +} + +// Test header function with nil options - these are expected to panic +// The functions require non-nil *s3.Options +func TestHeaderFunctionsWithNilOptions(t *testing.T) { + tests := []struct { + name string + apply func(*s3.Options) + }{ + {"WithHeader", func(o *s3.Options) { WithHeader("X-Test", "value")(o) }}, + {"WithQuery", func(o *s3.Options) { WithQuery("test")(o) }}, + {"WithCompareAndSwap", func(o *s3.Options) { WithCompareAndSwap()(o) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + // These functions are expected to panic with nil options + if r := recover(); r == nil { + t.Errorf("%s(nil) did not panic, expected panic with nil options", tt.name) + } + }() + tt.apply(nil) + }) + } +} + +// Test WithHeader with various key/value combinations +func TestWithHeader_variousInputs(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + {"standard header", "X-Custom-Header", "value"}, + {"header with numbers", "X-Header-123", "value456"}, + {"header with hyphens", "X-My-Custom-Header", "my-value"}, + {"empty value", "X-Empty", ""}, + {"value with spaces", "X-Spaced", "value with spaces"}, + {"value with special chars", "X-Special", "value:with;special=chars"}, + {"unicode key", "X-Unicode", "你好"}, + {"unicode value", "X-Header", "мир"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &s3.Options{} + WithHeader(tt.key, tt.value)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithHeader() did not add any APIOptions") + } + }) + } +} + +// Test that Region type works as expected +func TestRegionType(t *testing.T) { + // Test that Region is a string type + var r Region = "test-region" + if string(r) != "test-region" { + t.Errorf("Region type conversion failed: got %q", string(r)) + } + + // Test that Region constants can be used in slices + regions := []Region{FRA, SJC, LHR} + if len(regions) != 3 { + t.Errorf("Region slice length: got %d, want 3", len(regions)) + } + + // Test that Region can be compared + if FRA != "fra" { + t.Errorf("Region comparison failed: FRA = %q, want %q", FRA, "fra") + } +} + +// Test that functions can be called directly +func TestFunctionsCanBeCalled(t *testing.T) { + opts := &s3.Options{} + + // These should all work without panicking + WithHeader("X-Test", "value")(opts) + WithQuery("test")(opts) + WithCompareAndSwap()(opts) + WithEnableSnapshot()(opts) + WithRename()(opts) + + // Verify options were added + if len(opts.APIOptions) != 5 { + t.Errorf("Expected 5 APIOptions, got %d", len(opts.APIOptions)) + } +} + +// Test time formatting edge cases +func TestTimeFormattingEdgeCases(t *testing.T) { + tests := []struct { + name string + t time.Time + }{ + { + name: "zero time", + t: time.Time{}, + }, + { + name: "far future", + t: time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC), + }, + { + name: "far past", + t: time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "with monotonic clock", + t: time.Now().Add(time.Hour), + }, + } + + for _, tt := range tests { + t.Run(tt.name+" modified since", func(t *testing.T) { + opts := &s3.Options{} + WithModifiedSince(tt.t)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithModifiedSince() did not add any APIOptions") + } + }) + + t.Run(tt.name+" unmodified since", func(t *testing.T) { + opts := &s3.Options{} + WithUnmodifiedSince(tt.t)(opts) + + if len(opts.APIOptions) == 0 { + t.Error("WithUnmodifiedSince() did not add any APIOptions") + } + }) + } +} + +// Benchmark tests for header creation +func BenchmarkWithHeader(b *testing.B) { + opts := &s3.Options{} + b.ResetTimer() + for i := 0; i < b.N; i++ { + WithHeader("X-Test", "value")(opts) + } +} + +func BenchmarkWithStaticReplicationRegions(b *testing.B) { + regions := []Region{FRA, SJC, LHR, NRT, SYD} + b.ResetTimer() + for i := 0; i < b.N; i++ { + opts := &s3.Options{} + WithStaticReplicationRegions(regions)(opts) + } +} + +func BenchmarkWithQuery(b *testing.B) { + query := "WHERE `Last-Modified` > \"2023-01-15T08:30:00Z\" AND `Content-Type` = \"text/javascript\"" + b.ResetTimer() + for i := 0; i < b.N; i++ { + opts := &s3.Options{} + WithQuery(query)(opts) + } +} + +func BenchmarkWithModifiedSince(b *testing.B) { + t := time.Date(2023, 1, 15, 8, 30, 0, 0, time.UTC) + b.ResetTimer() + for i := 0; i < b.N; i++ { + opts := &s3.Options{} + WithModifiedSince(t)(opts) + } +} + +func BenchmarkWithTakeSnapshot(b *testing.B) { + desc := "Backup before migration" + b.ResetTimer() + for i := 0; i < b.N; i++ { + opts := &s3.Options{} + WithTakeSnapshot(desc)(opts) + } +} \ No newline at end of file From a4ab876b0a58d444475872b7688d61a3345acb82 Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:35:15 -0500 Subject: [PATCH 04/11] fix(go): add type assertion safety in HeadBucketForkOrSnapshot Add proper error handling for the middleware response type assertion to prevent potential panics from unsafe type casts. --- go/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/go/client.go b/go/client.go index ca6279e..e4664f7 100644 --- a/go/client.go +++ b/go/client.go @@ -2,6 +2,7 @@ package storage import ( "context" + "fmt" "net/http" "github.com/aws/aws-sdk-go-v2/aws" @@ -57,7 +58,10 @@ func (c *Client) HeadBucketForkOrSnapshot(ctx context.Context, in *s3.HeadBucket return nil, err } - rawResp := middleware.GetRawResponse(resp.ResultMetadata).(*http.Response) + rawResp, ok := middleware.GetRawResponse(resp.ResultMetadata).(*http.Response) + if !ok { + return nil, fmt.Errorf("unexpected response type from middleware") + } return &HeadBucketForkOrSnapshotOutput{ SnapshotsEnabled: rawResp.Header.Get("X-Tigris-Enable-Snapshot") == "true", SourceBucket: rawResp.Header.Get("X-Tigris-Fork-Source-Bucket"), From e5402bdeb6f785f2cd8deaf95c94d95a65ea102f Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:38:33 -0500 Subject: [PATCH 05/11] docs(go): improve CreateBucketFork documentation fix(go): URL-escape snapshot description in WithTakeSnapshot - Add godoc comment mentioning WithSnapshotVersion option - Fix snapshot description to be properly URL-encoded --- go/client.go | 2 ++ go/tigrisheaders/tigrisheaders.go | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/go/client.go b/go/client.go index e4664f7..74c0ff9 100644 --- a/go/client.go +++ b/go/client.go @@ -17,6 +17,8 @@ type Client struct { } // 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. func (c *Client) CreateBucketFork(ctx context.Context, source, target string, opts ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { opts = append(opts, tigrisheaders.WithHeader("X-Tigris-Fork-Source-Bucket", source)) diff --git a/go/tigrisheaders/tigrisheaders.go b/go/tigrisheaders/tigrisheaders.go index 6e15b92..400c8d1 100644 --- a/go/tigrisheaders/tigrisheaders.go +++ b/go/tigrisheaders/tigrisheaders.go @@ -5,6 +5,7 @@ package tigrisheaders import ( "fmt" + "net/url" "strings" "time" @@ -126,7 +127,7 @@ func WithEnableSnapshot() func(*s3.Options) { // // [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#creating-a-snapshot func WithTakeSnapshot(desc string) func(*s3.Options) { - return WithHeader("X-Tigris-Snapshot", fmt.Sprintf("true; desc=%s", desc)) + return WithHeader("X-Tigris-Snapshot", fmt.Sprintf("true; desc=%s", url.QueryEscape(desc))) } // WithSnapshotVersion tells Tigris to use a given snapshot when doing ListObjectsV2, GetObject, or HeadObject calls. From 88ecabfe1b350c562d7f2f058b048f26ec5161eb Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:41:50 -0500 Subject: [PATCH 06/11] fix(go): handle nil credentials in New client initialization Prevent setting nil credentials in AWS config, allowing the credential chain to run its default resolution process. --- go/storage.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go/storage.go b/go/storage.go index d296bd2..9c6dfcb 100644 --- a/go/storage.go +++ b/go/storage.go @@ -97,7 +97,9 @@ func New(ctx context.Context, options ...Option) (*Client, error) { opts.BaseEndpoint = aws.String(o.BaseEndpoint) opts.Region = o.Region opts.UsePathStyle = o.UsePathStyle - opts.Credentials = creds + if creds != nil { + opts.Credentials = creds + } }) return &Client{ From 03e3812e674b78cea15c957fe6f105453957605b Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:46:27 -0500 Subject: [PATCH 07/11] ci(javascript): run JS tests on different OSes and architectures just in case Signed-off-by: Xe Iaso --- .github/workflows/javascript.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml index 2a9eec2..5e55730 100644 --- a/.github/workflows/javascript.yaml +++ b/.github/workflows/javascript.yaml @@ -8,7 +8,15 @@ on: jobs: javascript_tests: - runs-on: ubuntu-latest + strategy: + matrix: + os: + - ubuntu-24.04 + - windows-2025 + - macos-15 + - ubuntu-24.04-arm + - windows-11-arm + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: @@ -17,7 +25,7 @@ jobs: # Set up Node.js - uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "latest" - name: Install Dependencies run: npm ci From eb446cdba50a03c42610a4bc23e2c551f4172f5f Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:50:09 -0500 Subject: [PATCH 08/11] docs(go): fix package name and function name in doc comments - Correct package description from "tigrissdk" to "storage" - Fix function name comment to match WithCreateObjectIfNotExists --- go/storage.go | 2 +- go/tigrisheaders/tigrisheaders.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go/storage.go b/go/storage.go index 9c6dfcb..a61fd70 100644 --- a/go/storage.go +++ b/go/storage.go @@ -1,4 +1,4 @@ -// Package tigrissdk contains a Tigris client and helpers for interacting with Tigris. +// Package storage contains a Tigris client and helpers for interacting with Tigris. // // Tigris is a cloud storage service that provides a simple, scalable, and secure object storage solution. It is based on the S3 API, but has additional features that need these helpers. package storage diff --git a/go/tigrisheaders/tigrisheaders.go b/go/tigrisheaders/tigrisheaders.go index 400c8d1..d866f1d 100644 --- a/go/tigrisheaders/tigrisheaders.go +++ b/go/tigrisheaders/tigrisheaders.go @@ -65,7 +65,7 @@ func WithQuery(query string) func(*s3.Options) { return WithHeader("X-Tigris-Query", query) } -// WithCreateIfNotExists will create the object if it doesn't exist. +// WithCreateObjectIfNotExists will create the object if it doesn't exist. // // See the Tigris documentation[1] for more information. // From be21021ca2b4c6439f4dad71783d349a90e7d9aa Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:53:30 -0500 Subject: [PATCH 09/11] ci(javascript): disable tests on ubuntu-arm for now Signed-off-by: Xe Iaso --- .github/workflows/javascript.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml index 5e55730..3acd4e0 100644 --- a/.github/workflows/javascript.yaml +++ b/.github/workflows/javascript.yaml @@ -14,7 +14,7 @@ jobs: - ubuntu-24.04 - windows-2025 - macos-15 - - ubuntu-24.04-arm + #- ubuntu-24.04-arm - windows-11-arm runs-on: ${{ matrix.os }} steps: From d8fc0eefbbc44f3765e03500eb5c7677ce13d31b Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 11:55:46 -0500 Subject: [PATCH 10/11] ci(javascript): only ubuntu amd64 for now, windows is broken too Signed-off-by: Xe Iaso --- .github/workflows/javascript.yaml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml index 3acd4e0..27bf221 100644 --- a/.github/workflows/javascript.yaml +++ b/.github/workflows/javascript.yaml @@ -8,15 +8,7 @@ on: jobs: javascript_tests: - strategy: - matrix: - os: - - ubuntu-24.04 - - windows-2025 - - macos-15 - #- ubuntu-24.04-arm - - windows-11-arm - runs-on: ${{ matrix.os }} + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 with: From f756dad3c5818f94d6feced70963653e09414cae Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 9 Jan 2026 12:05:16 -0500 Subject: [PATCH 11/11] fix(go): correct snapshot header parameter name Change 'desc=' to 'name=' in WithTakeSnapshot to match the Tigris API specification for snapshot creation. Assisted-by: GLM 4.7 via Claude Code --- go/tigrisheaders/tigrisheaders.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/tigrisheaders/tigrisheaders.go b/go/tigrisheaders/tigrisheaders.go index d866f1d..b4ca840 100644 --- a/go/tigrisheaders/tigrisheaders.go +++ b/go/tigrisheaders/tigrisheaders.go @@ -127,7 +127,7 @@ func WithEnableSnapshot() func(*s3.Options) { // // [1]: https://www.tigrisdata.com/docs/buckets/snapshots-and-forks/#creating-a-snapshot func WithTakeSnapshot(desc string) func(*s3.Options) { - return WithHeader("X-Tigris-Snapshot", fmt.Sprintf("true; desc=%s", url.QueryEscape(desc))) + return WithHeader("X-Tigris-Snapshot", fmt.Sprintf("true; name=%s", url.QueryEscape(desc))) } // WithSnapshotVersion tells Tigris to use a given snapshot when doing ListObjectsV2, GetObject, or HeadObject calls.