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:
Rohan Verma
2025-11-27 16:48:58 +05:30
parent 11cad3573f
commit 9219b63613
6 changed files with 677 additions and 9 deletions
+68
View File
@@ -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
View File
@@ -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.
+191
View File
@@ -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
}
+193
View File
@@ -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
}
+45 -3
View File
@@ -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
+173
View File
@@ -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))
}
}