feat: add object versioning support
Implements object versioning capabilities (v0.15.0) including: - Bucket APIs: PutBucketVersioning, GetBucketVersioning - Object Listing: ListVersions (lists versions and delete markers) - Object Operations: Updated FileDownload, FileDelete, and FileDetails to support VersionId - Integration tests covering the full versioning workflow (enable, upload versions, list, download specific version, delete specific version) Documentation updated in README.md and ROADMAP.md. "I have implemented Object Versioning (v0.15.0) as per the roadmap."
This commit is contained in:
@@ -16,6 +16,7 @@ using AWS Signature Version 4.
|
||||
- **Simple List API** with pagination, prefix filtering, and delimiter grouping
|
||||
- **Iterator-based ListAll** for memory-efficient large bucket iteration (Go 1.23+)
|
||||
- **Presigned URL generation** for secure browser uploads/downloads (including multipart)
|
||||
- **Object Versioning** - Enable versioning, list versions, and access specific object versions
|
||||
- **IAM credential support** for EC2 instances
|
||||
- **Comprehensive test coverage**
|
||||
- **Zero dependencies** - uses only Go standard library
|
||||
@@ -417,6 +418,73 @@ Note: Tag support during upload varies by operation:
|
||||
- **FileUpload** (POST): ⚠️ AWS S3 supported, MinIO limited (does not support tags in POST policy)
|
||||
- **CopyObject**: ✅ Full support (handles MinIO/R2 signature quirks automatically)
|
||||
|
||||
### Object Versioning
|
||||
|
||||
Enable and manage object versions to protect against accidental deletion or overwrite.
|
||||
|
||||
#### Enable Versioning
|
||||
|
||||
```go
|
||||
err := s3.PutBucketVersioning(simples3.PutBucketVersioningInput{
|
||||
Bucket: "my-bucket",
|
||||
Status: "Enabled", // or "Suspended"
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Check status
|
||||
config, err := s3.GetBucketVersioning("my-bucket")
|
||||
fmt.Printf("Versioning status: %s\n", config.Status)
|
||||
```
|
||||
|
||||
#### List Object Versions
|
||||
|
||||
List all versions of objects in a bucket:
|
||||
|
||||
```go
|
||||
result, err := s3.ListVersions(simples3.ListVersionsInput{
|
||||
Bucket: "my-bucket",
|
||||
Prefix: "important-file.txt",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, version := range result.Versions {
|
||||
fmt.Printf("Key: %s, VersionId: %s, IsLatest: %v\n",
|
||||
version.Key, version.VersionId, version.IsLatest)
|
||||
}
|
||||
```
|
||||
|
||||
#### Download Specific Version
|
||||
|
||||
Retrieve a specific version of an object:
|
||||
|
||||
```go
|
||||
file, err := s3.FileDownload(simples3.DownloadInput{
|
||||
Bucket: "my-bucket",
|
||||
ObjectKey: "my-file.txt",
|
||||
VersionId: "v1-version-id",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Use file (io.ReadCloser) normally
|
||||
```
|
||||
|
||||
#### Delete Specific Version
|
||||
|
||||
Delete a specific version of an object:
|
||||
|
||||
```go
|
||||
err := s3.FileDelete(simples3.DeleteInput{
|
||||
Bucket: "my-bucket",
|
||||
ObjectKey: "my-file.txt",
|
||||
VersionId: "v1-version-id",
|
||||
})
|
||||
```
|
||||
|
||||
### Multipart Upload
|
||||
|
||||
For large files (>100MB), use multipart upload for better performance, resumability, and parallel uploads.
|
||||
|
||||
+7
-6
@@ -131,15 +131,16 @@ Features Implemented:
|
||||
|
||||
---
|
||||
|
||||
## v0.15.0 - Object Versioning
|
||||
## v0.15.0 - Object Versioning ✅ COMPLETED
|
||||
**Scope**: Version control
|
||||
**Size**: Medium (5 APIs, ~300 LOC)
|
||||
**Released**: Nov 27, 2025
|
||||
|
||||
- [ ] PutBucketVersioning
|
||||
- [ ] GetBucketVersioning
|
||||
- [ ] ListObjectVersions
|
||||
- [ ] GetObjectVersion
|
||||
- [ ] DeleteObjectVersion (with version ID)
|
||||
- [x] PutBucketVersioning
|
||||
- [x] GetBucketVersioning
|
||||
- [x] ListObjectVersions
|
||||
- [x] GetObjectVersion (via FileDownload)
|
||||
- [x] DeleteObjectVersion (via FileDelete)
|
||||
|
||||
**Why**: Important for data safety. More complex due to version handling.
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
package simples3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -244,3 +249,189 @@ func (s3 *S3) DeleteBucket(input DeleteBucketInput) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VersioningConfiguration represents the versioning configuration of a bucket.
|
||||
type VersioningConfiguration struct {
|
||||
Status string `xml:"Status"` // Enabled or Suspended
|
||||
MfaDelete string `xml:"MfaDelete,omitempty"` // Enabled or Disabled
|
||||
}
|
||||
|
||||
// PutBucketVersioningInput is passed to PutBucketVersioning as a parameter.
|
||||
type PutBucketVersioningInput struct {
|
||||
// Required: The name of the bucket
|
||||
Bucket string
|
||||
|
||||
// Required: Versioning status (Enabled or Suspended)
|
||||
Status string
|
||||
|
||||
// Optional: MFA Delete status (Enabled or Disabled)
|
||||
// Note: Requires MFA authentication in the request, which is not yet supported
|
||||
MfaDelete string
|
||||
}
|
||||
|
||||
// GetBucketVersioningOutput is returned by GetBucketVersioning.
|
||||
type GetBucketVersioningOutput struct {
|
||||
Status string
|
||||
MfaDelete string
|
||||
}
|
||||
|
||||
// versioningConfigurationXML is the internal type for XML marshaling.
|
||||
type versioningConfigurationXML struct {
|
||||
XMLName xml.Name `xml:"VersioningConfiguration"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
Status string `xml:"Status"`
|
||||
MfaDelete string `xml:"MfaDelete,omitempty"`
|
||||
}
|
||||
|
||||
// PutBucketVersioning sets the versioning configuration for a bucket.
|
||||
func (s3 *S3) PutBucketVersioning(input PutBucketVersioningInput) error {
|
||||
// Validate input
|
||||
if input.Bucket == "" {
|
||||
return fmt.Errorf("bucket name is required")
|
||||
}
|
||||
if input.Status != "Enabled" && input.Status != "Suspended" {
|
||||
return fmt.Errorf("status must be 'Enabled' or 'Suspended'")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build XML request body
|
||||
config := versioningConfigurationXML{
|
||||
XMLNS: "http://s3.amazonaws.com/doc/2006-03-01/",
|
||||
Status: input.Status,
|
||||
MfaDelete: input.MfaDelete,
|
||||
}
|
||||
|
||||
xmlBody, err := xml.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate Content-MD5
|
||||
md5Hash := md5.Sum(xmlBody)
|
||||
contentMD5 := base64.StdEncoding.EncodeToString(md5Hash[:])
|
||||
|
||||
// Build URL with ?versioning query parameter
|
||||
baseURL := s3.getURL(input.Bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsedURL.RawQuery = "versioning"
|
||||
|
||||
// Create PUT request
|
||||
req, err := http.NewRequest(http.MethodPut, parsedURL.String(), bytes.NewReader(xmlBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.ContentLength = int64(len(xmlBody))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
req.Header.Set("Content-MD5", contentMD5)
|
||||
|
||||
// Calculate SHA256
|
||||
h := sha256.New()
|
||||
h.Write(xmlBody)
|
||||
req.Header.Set("x-amz-content-sha256", fmt.Sprintf("%x", h.Sum(nil)))
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBucketVersioning gets the versioning configuration for a bucket.
|
||||
func (s3 *S3) GetBucketVersioning(bucket string) (GetBucketVersioningOutput, error) {
|
||||
// Validate input
|
||||
if bucket == "" {
|
||||
return GetBucketVersioningOutput{}, fmt.Errorf("bucket name is required")
|
||||
}
|
||||
|
||||
// Renew IAM token if needed
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
|
||||
// Build URL with ?versioning query parameter
|
||||
baseURL := s3.getURL(bucket)
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
parsedURL.RawQuery = "versioning"
|
||||
|
||||
// Create GET request
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
|
||||
// Sign the request
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
res.Body.Close()
|
||||
io.Copy(io.Discard, res.Body)
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
|
||||
// Handle non-OK status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return GetBucketVersioningOutput{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
var config VersioningConfiguration
|
||||
if err := xml.Unmarshal(body, &config); err != nil {
|
||||
return GetBucketVersioningOutput{}, err
|
||||
}
|
||||
|
||||
return GetBucketVersioningOutput{
|
||||
Status: config.Status,
|
||||
MfaDelete: config.MfaDelete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -258,3 +258,196 @@ func (s3 *S3) ListAll(input ListInput) (iter.Seq[Object], func() error) {
|
||||
|
||||
return seq, finish
|
||||
}
|
||||
|
||||
// ListVersionsInput is passed to ListVersions as a parameter.
|
||||
type ListVersionsInput struct {
|
||||
// Required: The name of the bucket to list versions from
|
||||
Bucket string
|
||||
|
||||
// Optional: A delimiter to group objects by (commonly "/")
|
||||
Delimiter string
|
||||
|
||||
// Optional: Only list objects starting with this prefix
|
||||
Prefix string
|
||||
|
||||
// Optional: Maximum number of objects to return
|
||||
MaxKeys int64
|
||||
|
||||
// Optional: Key to start listing after (used for pagination)
|
||||
KeyMarker string
|
||||
|
||||
// Optional: Version ID to start listing after (used for pagination)
|
||||
VersionIdMarker string
|
||||
}
|
||||
|
||||
// ObjectVersion represents a version of an object.
|
||||
type ObjectVersion struct {
|
||||
ETag string `xml:"ETag"`
|
||||
IsLatest bool `xml:"IsLatest"`
|
||||
Key string `xml:"Key"`
|
||||
LastModified string `xml:"LastModified"`
|
||||
Size int64 `xml:"Size"`
|
||||
StorageClass string `xml:"StorageClass"`
|
||||
VersionId string `xml:"VersionId"`
|
||||
Owner struct {
|
||||
ID string `xml:"ID"`
|
||||
DisplayName string `xml:"DisplayName"`
|
||||
} `xml:"Owner"`
|
||||
}
|
||||
|
||||
// DeleteMarker represents a delete marker for an object.
|
||||
type DeleteMarker struct {
|
||||
IsLatest bool `xml:"IsLatest"`
|
||||
Key string `xml:"Key"`
|
||||
LastModified string `xml:"LastModified"`
|
||||
VersionId string `xml:"VersionId"`
|
||||
Owner struct {
|
||||
ID string `xml:"ID"`
|
||||
DisplayName string `xml:"DisplayName"`
|
||||
} `xml:"Owner"`
|
||||
}
|
||||
|
||||
// ListVersionsResponse is returned by ListVersions.
|
||||
type ListVersionsResponse struct {
|
||||
Name string `xml:"Name"`
|
||||
Prefix string `xml:"Prefix"`
|
||||
KeyMarker string `xml:"KeyMarker"`
|
||||
VersionIdMarker string `xml:"VersionIdMarker"`
|
||||
MaxKeys int64 `xml:"MaxKeys"`
|
||||
Delimiter string `xml:"Delimiter"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
NextKeyMarker string `xml:"NextKeyMarker"`
|
||||
NextVersionIdMarker string `xml:"NextVersionIdMarker"`
|
||||
Versions []ObjectVersion `xml:"Version"`
|
||||
DeleteMarkers []DeleteMarker `xml:"DeleteMarker"`
|
||||
CommonPrefixes []string
|
||||
}
|
||||
|
||||
// ListVersions lists object versions in a bucket.
|
||||
func (s3 *S3) ListVersions(input ListVersionsInput) (ListVersionsResponse, error) {
|
||||
// Input validation
|
||||
if input.Bucket == "" {
|
||||
return ListVersionsResponse{}, fmt.Errorf("bucket name cannot be empty")
|
||||
}
|
||||
if input.MaxKeys < 0 {
|
||||
return ListVersionsResponse{}, fmt.Errorf("MaxKeys cannot be negative")
|
||||
}
|
||||
if input.MaxKeys > 1000 {
|
||||
return ListVersionsResponse{}, fmt.Errorf("MaxKeys cannot exceed 1000")
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
query := url.Values{}
|
||||
query.Set("versions", "") // Enables version listing
|
||||
|
||||
// Add optional parameters if they exist
|
||||
if input.Delimiter != "" {
|
||||
query.Set("delimiter", input.Delimiter)
|
||||
}
|
||||
if input.Prefix != "" {
|
||||
query.Set("prefix", input.Prefix)
|
||||
}
|
||||
if input.MaxKeys > 0 {
|
||||
query.Set("max-keys", fmt.Sprintf("%d", input.MaxKeys))
|
||||
}
|
||||
if input.KeyMarker != "" {
|
||||
query.Set("key-marker", input.KeyMarker)
|
||||
}
|
||||
if input.VersionIdMarker != "" {
|
||||
query.Set("version-id-marker", input.VersionIdMarker)
|
||||
}
|
||||
|
||||
// Build base URL
|
||||
baseURL := s3.getURL(input.Bucket)
|
||||
|
||||
// Parse URL and add query parameters
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
|
||||
// Apply AWS V4 signing
|
||||
if err := s3.renewIAMToken(); err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
if err := s3.signRequest(req); err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := s3.getClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
|
||||
// Close response body when done
|
||||
defer res.Body.Close()
|
||||
|
||||
// Read response body once
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
|
||||
// Handle response status codes
|
||||
if res.StatusCode != http.StatusOK {
|
||||
var s3Err S3Error
|
||||
if xmlErr := xml.Unmarshal(body, &s3Err); xmlErr == nil {
|
||||
return ListVersionsResponse{}, fmt.Errorf("S3 Error: %s - %s", s3Err.Code, s3Err.Message)
|
||||
}
|
||||
return ListVersionsResponse{}, fmt.Errorf("status code: %s: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
// Parse XML response using internal struct for CommonPrefixes handling
|
||||
var xmlResult struct {
|
||||
XMLName xml.Name `xml:"ListVersionsResult"`
|
||||
Name string `xml:"Name"`
|
||||
Prefix string `xml:"Prefix"`
|
||||
KeyMarker string `xml:"KeyMarker"`
|
||||
VersionIdMarker string `xml:"VersionIdMarker"`
|
||||
MaxKeys int64 `xml:"MaxKeys"`
|
||||
Delimiter string `xml:"Delimiter"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
NextKeyMarker string `xml:"NextKeyMarker"`
|
||||
NextVersionIdMarker string `xml:"NextVersionIdMarker"`
|
||||
Versions []ObjectVersion `xml:"Version"`
|
||||
DeleteMarkers []DeleteMarker `xml:"DeleteMarker"`
|
||||
CommonPrefixes []struct {
|
||||
Prefix string `xml:"Prefix"`
|
||||
} `xml:"CommonPrefixes"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &xmlResult); err != nil {
|
||||
return ListVersionsResponse{}, err
|
||||
}
|
||||
|
||||
// Convert to simple response format
|
||||
response := ListVersionsResponse{
|
||||
Name: xmlResult.Name,
|
||||
Prefix: xmlResult.Prefix,
|
||||
KeyMarker: xmlResult.KeyMarker,
|
||||
VersionIdMarker: xmlResult.VersionIdMarker,
|
||||
MaxKeys: xmlResult.MaxKeys,
|
||||
Delimiter: xmlResult.Delimiter,
|
||||
IsTruncated: xmlResult.IsTruncated,
|
||||
NextKeyMarker: xmlResult.NextKeyMarker,
|
||||
NextVersionIdMarker: xmlResult.NextVersionIdMarker,
|
||||
Versions: xmlResult.Versions,
|
||||
DeleteMarkers: xmlResult.DeleteMarkers,
|
||||
}
|
||||
|
||||
// Convert common prefixes
|
||||
for _, prefix := range xmlResult.CommonPrefixes {
|
||||
response.CommonPrefixes = append(response.CommonPrefixes, prefix.Prefix)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ import (
|
||||
type DownloadInput struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
VersionId string // Optional: Version ID of the object to download
|
||||
}
|
||||
|
||||
// DetailsInput is passed to FileDetails as a parameter.
|
||||
type DetailsInput struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
VersionId string // Optional: Version ID of the object to retrieve details for
|
||||
}
|
||||
|
||||
// DetailsResponse is returned by FileDetails.
|
||||
@@ -93,13 +95,27 @@ type PutResponse struct {
|
||||
type DeleteInput struct {
|
||||
Bucket string
|
||||
ObjectKey string
|
||||
VersionId string // Optional: Version ID of the object to delete
|
||||
}
|
||||
|
||||
// FileDownload makes a GET call and returns a io.ReadCloser.
|
||||
// After reading the response body, ensure closing the response.
|
||||
func (s3 *S3) FileDownload(u DownloadInput) (io.ReadCloser, error) {
|
||||
urlStr := s3.getURL(u.Bucket, u.ObjectKey)
|
||||
|
||||
if u.VersionId != "" {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := parsed.Query()
|
||||
q.Set("versionId", u.VersionId)
|
||||
parsed.RawQuery = q.Encode()
|
||||
urlStr = parsed.String()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet, s3.getURL(u.Bucket, u.ObjectKey), nil,
|
||||
http.MethodGet, urlStr, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -315,8 +331,21 @@ func (s3 *S3) FileUpload(u UploadInput) (UploadResponse, error) {
|
||||
// FileDelete makes a DELETE call with the file written as multipart
|
||||
// and on successful upload, checks for 204 No Content.
|
||||
func (s3 *S3) FileDelete(u DeleteInput) error {
|
||||
urlStr := s3.getURL(u.Bucket, u.ObjectKey)
|
||||
|
||||
if u.VersionId != "" {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := parsed.Query()
|
||||
q.Set("versionId", u.VersionId)
|
||||
parsed.RawQuery = q.Encode()
|
||||
urlStr = parsed.String()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodDelete, s3.getURL(u.Bucket, u.ObjectKey), nil,
|
||||
http.MethodDelete, urlStr, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -351,8 +380,21 @@ func (s3 *S3) FileDelete(u DeleteInput) error {
|
||||
}
|
||||
|
||||
func (s3 *S3) FileDetails(u DetailsInput) (DetailsResponse, error) {
|
||||
urlStr := s3.getURL(u.Bucket, u.ObjectKey)
|
||||
|
||||
if u.VersionId != "" {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return DetailsResponse{}, err
|
||||
}
|
||||
q := parsed.Query()
|
||||
q.Set("versionId", u.VersionId)
|
||||
parsed.RawQuery = q.Encode()
|
||||
urlStr = parsed.String()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodHead, s3.getURL(u.Bucket, u.ObjectKey), nil,
|
||||
http.MethodHead, urlStr, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return DetailsResponse{}, err
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package simples3
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVersioning(t *testing.T) {
|
||||
s3 := setupTestS3(t)
|
||||
bucket := fmt.Sprintf("simples3-versioning-test-%d", time.Now().Unix())
|
||||
|
||||
// Create bucket
|
||||
_, err := s3.CreateBucket(CreateBucketInput{Bucket: bucket})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBucket failed: %v", err)
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
defer func() {
|
||||
// List all versions and delete markers to clean up
|
||||
listResp, err := s3.ListVersions(ListVersionsInput{Bucket: bucket})
|
||||
if err == nil {
|
||||
for _, v := range listResp.Versions {
|
||||
s3.FileDelete(DeleteInput{Bucket: bucket, ObjectKey: v.Key, VersionId: v.VersionId})
|
||||
}
|
||||
for _, d := range listResp.DeleteMarkers {
|
||||
s3.FileDelete(DeleteInput{Bucket: bucket, ObjectKey: d.Key, VersionId: d.VersionId})
|
||||
}
|
||||
}
|
||||
s3.DeleteBucket(DeleteBucketInput{Bucket: bucket})
|
||||
}()
|
||||
|
||||
// 1. Enable Versioning
|
||||
err = s3.PutBucketVersioning(PutBucketVersioningInput{
|
||||
Bucket: bucket,
|
||||
Status: "Enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutBucketVersioning failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Get Versioning Status
|
||||
vConf, err := s3.GetBucketVersioning(bucket)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketVersioning failed: %v", err)
|
||||
}
|
||||
if vConf.Status != "Enabled" {
|
||||
t.Errorf("Expected versioning Enabled, got %s", vConf.Status)
|
||||
}
|
||||
|
||||
key := "test-object.txt"
|
||||
|
||||
// 3. Upload v1
|
||||
_, err = s3.FilePut(UploadInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
Body: strings.NewReader("v1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilePut v1 failed: %v", err)
|
||||
}
|
||||
|
||||
// Sleep briefly to ensure timestamp difference (MinIO sometimes has granularity issues)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 4. Upload v2
|
||||
_, err = s3.FilePut(UploadInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
Body: strings.NewReader("v2"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilePut v2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 5. List Versions
|
||||
listResp, err := s3.ListVersions(ListVersionsInput{
|
||||
Bucket: bucket,
|
||||
Prefix: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListVersions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(listResp.Versions) != 2 {
|
||||
t.Errorf("Expected 2 versions, got %d", len(listResp.Versions))
|
||||
}
|
||||
|
||||
// Identify versions
|
||||
var v1Id, v2Id string
|
||||
// ListVersions returns latest first usually.
|
||||
for _, v := range listResp.Versions {
|
||||
if v.IsLatest {
|
||||
v2Id = v.VersionId
|
||||
} else {
|
||||
v1Id = v.VersionId
|
||||
}
|
||||
}
|
||||
|
||||
if v1Id == "" || v2Id == "" {
|
||||
t.Fatalf("Failed to identify v1 and v2 IDs. Versions: %+v", listResp.Versions)
|
||||
}
|
||||
|
||||
// 6. Download v1 specific version
|
||||
rc, err := s3.FileDownload(DownloadInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v1Id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FileDownload v1 failed: %v", err)
|
||||
}
|
||||
content, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if string(content) != "v1" {
|
||||
t.Errorf("Expected v1 content 'v1', got '%s'", string(content))
|
||||
}
|
||||
|
||||
// 7. Download v2 specific version
|
||||
rc, err = s3.FileDownload(DownloadInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v2Id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FileDownload v2 failed: %v", err)
|
||||
}
|
||||
content, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if string(content) != "v2" {
|
||||
t.Errorf("Expected v2 content 'v2', got '%s'", string(content))
|
||||
}
|
||||
|
||||
// 8. Get Details for v1
|
||||
details, err := s3.FileDetails(DetailsInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v1Id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FileDetails v1 failed: %v", err)
|
||||
}
|
||||
if details.ContentLength != "2" { // "v1" is 2 bytes
|
||||
t.Errorf("Expected v1 size 2, got %s", details.ContentLength)
|
||||
}
|
||||
|
||||
// 9. Delete v2 (latest)
|
||||
err = s3.FileDelete(DeleteInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
VersionId: v2Id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FileDelete v2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 10. Verify current object is v1
|
||||
rc, err = s3.FileDownload(DownloadInput{
|
||||
Bucket: bucket,
|
||||
ObjectKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FileDownload current failed: %v", err)
|
||||
}
|
||||
content, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if string(content) != "v1" {
|
||||
t.Errorf("Expected current content 'v1', got '%s'", string(content))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user