Skip to content

Commit

Permalink
ft: BKTCLT-22 implement ListObjectVersions
Browse files Browse the repository at this point in the history
Add the Go binding for ListObjectVersions (`listingType=DelimiterVersions`).

An extra option is provided to truncate the result to a specific last
marker (useful for metadata migration tasks).
  • Loading branch information
jonathan-gramain committed Sep 27, 2024
1 parent 3d67c95 commit 73572b8
Show file tree
Hide file tree
Showing 4 changed files with 535 additions and 0 deletions.
115 changes: 115 additions & 0 deletions go/listobjectversions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package bucketclient

import (
"context"
"encoding/json"
"fmt"
"net/url"
)

type ListObjectVersionsOption func(*listObjectVersionsOptionSet) error

func ListObjectVersionsMarkerOption(keyMarker string, versionIdMarker string) ListObjectVersionsOption {
return func(opts *listObjectVersionsOptionSet) error {
opts.keyMarker = keyMarker
opts.versionIdMarker = versionIdMarker
return nil
}
}

func ListObjectVersionsMaxKeysOption(maxKeys int) ListObjectVersionsOption {
return func(opts *listObjectVersionsOptionSet) error {
if maxKeys < 0 || maxKeys > 10000 {
return fmt.Errorf("maxKeys=%d is out of the valid range [0, 10000]", maxKeys)
}
opts.maxKeys = maxKeys
return nil
}
}

// ListObjectVersionsLastMarkerOption option makes the listing behave
// as if the bucket contains no object which key/versionId is strictly
// higher than the pair "lastKeyMarker/lastVersionIdMarker".
//
// Note: this option is not implemented natively by bucketd, hence the
// Go client may truncate the result and adjust the "IsTruncated"
// field accordingly, before returning the truncated response to the
// client.
func ListObjectVersionsLastMarkerOption(lastKeyMarker string, lastVersionIdMarker string) ListObjectVersionsOption {
return func(opts *listObjectVersionsOptionSet) error {
opts.lastKeyMarker = lastKeyMarker
opts.lastVersionIdMarker = lastVersionIdMarker
return nil
}
}

type ListObjectVersionsEntry struct {
Key string `json:"key"`
VersionId string `json:"versionId"`
Value string `json:"value"`
}

type ListObjectVersionsResponse struct {
Versions []ListObjectVersionsEntry
CommonPrefixes []string
IsTruncated bool
NextKeyMarker string `json:",omitempty"`
NextVersionIdMarker string `json:",omitempty"`
}

type listObjectVersionsOptionSet struct {
keyMarker string
versionIdMarker string
maxKeys int
lastKeyMarker string
lastVersionIdMarker string
}

func parseListObjectVersionsOptions(opts []ListObjectVersionsOption) (listObjectVersionsOptionSet, error) {
parsedOpts := listObjectVersionsOptionSet{
keyMarker: "",
versionIdMarker: "",
maxKeys: -1,
}
for _, opt := range opts {
err := opt(&parsedOpts)
if err != nil {
return parsedOpts, err
}
}
return parsedOpts, nil
}

func (client *BucketClient) ListObjectVersions(ctx context.Context,
bucketName string, opts ...ListObjectVersionsOption) (*ListObjectVersionsResponse, error) {
resource := fmt.Sprintf("/default/bucket/%s?listingType=DelimiterVersions", bucketName)
options, err := parseListObjectVersionsOptions(opts)
if err != nil {
return nil, &BucketClientError{
"ListObjectVersions", "GET", client.Endpoint, resource, 0, "", err,
}
}
if options.keyMarker != "" {
resource += fmt.Sprintf("&keyMarker=%s&versionIdMarker=%s",
url.QueryEscape(options.keyMarker),
url.QueryEscape(options.versionIdMarker))
}
if options.maxKeys != -1 {
resource += fmt.Sprintf("&maxKeys=%d", options.maxKeys)
}
responseBody, err := client.Request(ctx, "ListObjectVersions", "GET", resource)
if err != nil {
return nil, err
}
var parsedResponse = new(ListObjectVersionsResponse)
jsonErr := json.Unmarshal(responseBody, parsedResponse)
if jsonErr != nil {
return nil, ErrorMalformedResponse("ListObjectVersions", "GET",
client.Endpoint, resource, jsonErr)
}
if options.lastKeyMarker != "" {
truncateListObjectVersionsResponse(parsedResponse,
options.lastKeyMarker, options.lastVersionIdMarker)
}
return parsedResponse, nil
}
72 changes: 72 additions & 0 deletions go/listobjectversions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package bucketclient_test

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/jarcoal/httpmock"

"github.com/scality/bucketclient/go"
)

var _ = Describe("ListObjectVersions()", func() {
It("returns an empty listing result", func(ctx SpecContext) {
httpmock.RegisterResponder(
"GET", "/default/bucket/my-bucket?listingType=DelimiterVersions",
httpmock.NewStringResponder(200, `{
"Versions": [],
"CommonPrefixes": [],
"IsTruncated": false
}
`))

Expect(client.ListObjectVersions(ctx, "my-bucket")).To(Equal(
&bucketclient.ListObjectVersionsResponse{
Versions: []bucketclient.ListObjectVersionsEntry{},
CommonPrefixes: []string{},
IsTruncated: false,
}))
})

It("returns a non-empty listing result with URL-encoded marker, maxKeys and truncation", func(ctx SpecContext) {
httpmock.RegisterResponder(
"GET", "/default/bucket/my-bucket?listingType=DelimiterVersions"+
"&keyMarker=foo%2Fbar&versionIdMarker=123+4&maxKeys=3",
httpmock.NewStringResponder(200, `{
"Versions": [
{"key": "fop", "versionId": "123"},
{"key": "goo", "versionId": "124"},
{"key": "hop", "versionId": "125"}
],
"CommonPrefixes": [],
"IsTruncated": true,
"NextKeyMarker": "hop",
"NextVersionIdMarker": "125"
}
`))

Expect(client.ListObjectVersions(ctx, "my-bucket",
bucketclient.ListObjectVersionsMarkerOption("foo/bar", "123 4"),
bucketclient.ListObjectVersionsMaxKeysOption(3),
bucketclient.ListObjectVersionsLastMarkerOption("hoo", "126"),
)).To(Equal(&bucketclient.ListObjectVersionsResponse{
Versions: []bucketclient.ListObjectVersionsEntry{
bucketclient.ListObjectVersionsEntry{Key: "fop", VersionId: "123"},
bucketclient.ListObjectVersionsEntry{Key: "goo", VersionId: "124"},
},
CommonPrefixes: []string{},
IsTruncated: false,
}))
})

It("returns an error with malformed response", func(ctx SpecContext) {
httpmock.RegisterResponder(
"GET", "/default/bucket/my-bucket?listingType=DelimiterVersions",
httpmock.NewStringResponder(200, "{OOPS"),
)

_, err := client.ListObjectVersions(ctx, "my-bucket")
Expect(err).To(MatchError(ContainSubstring("malformed response body")))
})

})
57 changes: 57 additions & 0 deletions go/listobjectversionsutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package bucketclient

import ()

// CompareVersionsListingMarkers is a helper function that returns -1,
// 0, or 1 if the pair keyMarker1/versionIdMarker1 is
// lexicographically, respectively strictly lower, equal, or strictly
// higher than the pair keyMarker2/versionIdMarker2.
func CompareVersionsListingMarkers(keyMarker1 string, versionIdMarker1 string,
keyMarker2 string, versionIdMarker2 string) int {
// if key markers are different, versionId markers are ignored
if keyMarker1 != keyMarker2 {
if keyMarker1 < keyMarker2 {
return -1
}
return 1
}
// if key markers are equal, versionId markers are compared
if versionIdMarker1 != versionIdMarker2 {
if versionIdMarker1 < versionIdMarker2 {
return -1
}
return 1
}
return 0
}

// truncateListObjectVersionsResponse discards entries which
// key/versionId pair is strictly higher than the
// lastKeyMarker/lastVersionIdMarker pair, and may also change the
// IsTruncated attribute.
func truncateListObjectVersionsResponse(listResponse *ListObjectVersionsResponse,
lastKeyMarker string, lastVersionIdMarker string) {
if listResponse.IsTruncated {
cmp := CompareVersionsListingMarkers(
listResponse.NextKeyMarker, listResponse.NextVersionIdMarker,
lastKeyMarker, lastVersionIdMarker)
if cmp < 0 {
return
}
}
var i int
for i = len(listResponse.Versions) - 1; i >= 0; i -= 1 {
cmp := CompareVersionsListingMarkers(
listResponse.Versions[i].Key, listResponse.Versions[i].VersionId,
lastKeyMarker, lastVersionIdMarker)
if cmp <= 0 {
break
}
}
listResponse.IsTruncated = false
listResponse.NextKeyMarker = ""
listResponse.NextVersionIdMarker = ""
if i+1 < len(listResponse.Versions) {
listResponse.Versions = listResponse.Versions[0 : i+1]
}
}
Loading

0 comments on commit 73572b8

Please sign in to comment.