diff --git a/_examples/r2-image-server/main.go b/_examples/r2-image-server/main.go index 60b5841..8254032 100644 --- a/_examples/r2-image-server/main.go +++ b/_examples/r2-image-server/main.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/syumai/workers" - "github.com/syumai/workers/cloudflare" + "github.com/syumai/workers/cloudflare/r2" ) // bucketName is R2 bucket name defined in wrangler.toml. @@ -23,8 +23,8 @@ func handleErr(w http.ResponseWriter, msg string, err error) { type server struct{} -func (s *server) bucket() (*cloudflare.R2Bucket, error) { - return cloudflare.NewR2Bucket(bucketName) +func (s *server) bucket() (*r2.Bucket, error) { + return r2.NewBucket(bucketName) } func (s *server) post(w http.ResponseWriter, req *http.Request, key string) { @@ -45,8 +45,8 @@ func (s *server) post(w http.ResponseWriter, req *http.Request, key string) { return } } - _, err = bucket.Put(key, req.Body, &cloudflare.R2PutOptions{ - HTTPMetadata: cloudflare.R2HTTPMetadata{ + _, err = bucket.Put(key, req.Body, &r2.PutOptions{ + HTTPMetadata: r2.HTTPMetadata{ ContentType: req.Header.Get("Content-Type"), }, CustomMetadata: map[string]string{"custom-key": "custom-value"}, diff --git a/_examples/r2-image-viewer/main.go b/_examples/r2-image-viewer/main.go index 924281a..6cb7353 100644 --- a/_examples/r2-image-viewer/main.go +++ b/_examples/r2-image-viewer/main.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/syumai/workers" - "github.com/syumai/workers/cloudflare" + "github.com/syumai/workers/cloudflare/r2" ) // bucketName is R2 bucket name defined in wrangler.toml. @@ -23,7 +23,7 @@ func handleErr(w http.ResponseWriter, msg string, err error) { // This example is based on implementation in syumai/workers-playground // - https://github.com/syumai/workers-playground/blob/e32881648ccc055e3690a0d9c750a834261c333e/r2-image-viewer/src/index.ts#L30 func handler(w http.ResponseWriter, req *http.Request) { - bucket, err := cloudflare.NewR2Bucket(bucketName) + bucket, err := r2.NewBucket(bucketName) if err != nil { handleErr(w, "failed to get R2Bucket\n", err) return diff --git a/cloudflare/r2.go b/cloudflare/r2.go new file mode 100644 index 0000000..453c713 --- /dev/null +++ b/cloudflare/r2.go @@ -0,0 +1,31 @@ +package cloudflare + +import ( + "github.com/syumai/workers/cloudflare/r2" +) + +// R2Bucket represents interface of Cloudflare Worker's R2 Bucket instance. +// Deprecated: use r2.Bucket instead. +type R2Bucket = r2.Bucket + +// NewR2Bucket returns R2Bucket for given variable name. +// Deprecated: use r2.NewBucket instead. +func NewR2Bucket(varName string) (*R2Bucket, error) { + return r2.NewBucket(varName) +} + +// R2PutOptions represents Cloudflare R2 put options. +// Deprecated: use r2.PutOptions instead. +type R2PutOptions = r2.PutOptions + +// R2Object represents Cloudflare R2 object. +// Deprecated: use r2.Object instead. +type R2Object = r2.Object + +// R2HTTPMetadata represents metadata of R2Object. +// Deprecated: use r2.HTTPMetadata instead. +type R2HTTPMetadata = r2.HTTPMetadata + +// R2Objects represents Cloudflare R2 objects. +// Deprecated: use r2.Objects instead. +type R2Objects = r2.Objects diff --git a/cloudflare/r2/bucket.go b/cloudflare/r2/bucket.go new file mode 100644 index 0000000..50e932a --- /dev/null +++ b/cloudflare/r2/bucket.go @@ -0,0 +1,135 @@ +package r2 + +import ( + "fmt" + "io" + "syscall/js" + + "github.com/syumai/workers/cloudflare/internal/cfruntimecontext" + "github.com/syumai/workers/internal/jsutil" +) + +// Bucket represents interface of Cloudflare Worker's R2 Bucket instance. +// - https://developers.cloudflare.com/r2/runtime-apis/#bucket-method-definitions +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1006 +type Bucket struct { + instance js.Value +} + +// NewBucket returns Bucket for given variable name. +// - variable name must be defined in wrangler.toml. +// - see example: https://github.com/syumai/workers/tree/main/_examples/r2-image-viewer +// - if the given variable name doesn't exist on runtime context, returns error. +// - This function panics when a runtime context is not found. +func NewBucket(varName string) (*Bucket, error) { + inst := cfruntimecontext.MustGetRuntimeContextEnv().Get(varName) + if inst.IsUndefined() { + return nil, fmt.Errorf("%s is undefined", varName) + } + return &Bucket{instance: inst}, nil +} + +// Head returns the result of `head` call to Bucket. +// - Body field of *Object is always nil for Head call. +// - if the object for given key doesn't exist, returns nil. +// - if a network error happens, returns error. +func (r *Bucket) Head(key string) (*Object, error) { + p := r.instance.Call("head", key) + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + if v.IsNull() { + return nil, nil + } + return toObject(v) +} + +// Get returns the result of `get` call to Bucket. +// - if the object for given key doesn't exist, returns nil. +// - if a network error happens, returns error. +func (r *Bucket) Get(key string) (*Object, error) { + p := r.instance.Call("get", key) + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + if v.IsNull() { + return nil, nil + } + return toObject(v) +} + +// PutOptions represents Cloudflare R2 put options. +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1128 +type PutOptions struct { + HTTPMetadata HTTPMetadata + CustomMetadata map[string]string + MD5 string +} + +func (opts *PutOptions) toJS() js.Value { + if opts == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + if opts.HTTPMetadata != (HTTPMetadata{}) { + obj.Set("httpMetadata", opts.HTTPMetadata.toJS()) + } + if opts.CustomMetadata != nil { + // convert map[string]string to map[string]any. + // This makes the map convertible to JS. + // see: https://pkg.go.dev/syscall/js#ValueOf + customMeta := make(map[string]any, len(opts.CustomMetadata)) + for k, v := range opts.CustomMetadata { + customMeta[k] = v + } + obj.Set("customMetadata", customMeta) + } + if opts.MD5 != "" { + obj.Set("md5", opts.MD5) + } + return obj +} + +// Put returns the result of `put` call to Bucket. +// - This method copies all bytes into memory for implementation restriction. +// - Body field of *Object is always nil for Put call. +// - if a network error happens, returns error. +func (r *Bucket) Put(key string, value io.ReadCloser, opts *PutOptions) (*Object, error) { + // fetch body cannot be ReadableStream. see: https://github.com/whatwg/fetch/issues/1438 + b, err := io.ReadAll(value) + if err != nil { + return nil, err + } + defer value.Close() + ua := jsutil.NewUint8Array(len(b)) + js.CopyBytesToJS(ua, b) + p := r.instance.Call("put", key, ua.Get("buffer"), opts.toJS()) + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + return toObject(v) +} + +// Delete returns the result of `delete` call to Bucket. +// - if a network error happens, returns error. +func (r *Bucket) Delete(key string) error { + p := r.instance.Call("delete", key) + if _, err := jsutil.AwaitPromise(p); err != nil { + return err + } + return nil +} + +// List returns the result of `list` call to Bucket. +// - if a network error happens, returns error. +func (r *Bucket) List() (*Objects, error) { + p := r.instance.Call("list") + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + return toObjects(v) +} diff --git a/cloudflare/r2/object.go b/cloudflare/r2/object.go new file mode 100644 index 0000000..18d5045 --- /dev/null +++ b/cloudflare/r2/object.go @@ -0,0 +1,120 @@ +package r2 + +import ( + "errors" + "fmt" + "io" + "syscall/js" + "time" + + "github.com/syumai/workers/internal/jsutil" +) + +// Object represents Cloudflare R2 object. +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094 +type Object struct { + instance js.Value + Key string + Version string + Size int + ETag string + HTTPETag string + Uploaded time.Time + HTTPMetadata HTTPMetadata + CustomMetadata map[string]string + // Body is a body of Object. + // This value is nil for the result of the `Head` or `Put` method. + Body io.Reader +} + +// TODO: implement +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1106 +// func (o *Object) WriteHTTPMetadata(headers http.Header) { +// } + +func (o *Object) BodyUsed() (bool, error) { + v := o.instance.Get("bodyUsed") + if v.IsUndefined() { + return false, errors.New("bodyUsed doesn't exist for this Object") + } + return v.Bool(), nil +} + +// toObject converts JavaScript side's Object to *Object. +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094 +func toObject(v js.Value) (*Object, error) { + uploaded, err := jsutil.DateToTime(v.Get("uploaded")) + if err != nil { + return nil, fmt.Errorf("error converting uploaded: %w", err) + } + r2Meta, err := toHTTPMetadata(v.Get("httpMetadata")) + if err != nil { + return nil, fmt.Errorf("error converting httpMetadata: %w", err) + } + bodyVal := v.Get("body") + var body io.Reader + if !bodyVal.IsUndefined() { + body = jsutil.ConvertReadableStreamToReadCloser(v.Get("body")) + } + return &Object{ + instance: v, + Key: v.Get("key").String(), + Version: v.Get("version").String(), + Size: v.Get("size").Int(), + ETag: v.Get("etag").String(), + HTTPETag: v.Get("httpEtag").String(), + Uploaded: uploaded, + HTTPMetadata: r2Meta, + CustomMetadata: jsutil.StrRecordToMap(v.Get("customMetadata")), + Body: body, + }, nil +} + +// HTTPMetadata represents metadata of Object. +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1053 +type HTTPMetadata struct { + ContentType string + ContentLanguage string + ContentDisposition string + ContentEncoding string + CacheControl string + CacheExpiry time.Time +} + +func toHTTPMetadata(v js.Value) (HTTPMetadata, error) { + if v.IsUndefined() || v.IsNull() { + return HTTPMetadata{}, nil + } + cacheExpiry, err := jsutil.MaybeDate(v.Get("cacheExpiry")) + if err != nil { + return HTTPMetadata{}, fmt.Errorf("error converting cacheExpiry: %w", err) + } + return HTTPMetadata{ + ContentType: jsutil.MaybeString(v.Get("contentType")), + ContentLanguage: jsutil.MaybeString(v.Get("contentLanguage")), + ContentDisposition: jsutil.MaybeString(v.Get("contentDisposition")), + ContentEncoding: jsutil.MaybeString(v.Get("contentEncoding")), + CacheControl: jsutil.MaybeString(v.Get("cacheControl")), + CacheExpiry: cacheExpiry, + }, nil +} + +func (md *HTTPMetadata) toJS() js.Value { + obj := jsutil.NewObject() + kv := map[string]string{ + "contentType": md.ContentType, + "contentLanguage": md.ContentLanguage, + "contentDisposition": md.ContentDisposition, + "contentEncoding": md.ContentEncoding, + "cacheControl": md.CacheControl, + } + for k, v := range kv { + if v != "" { + obj.Set(k, v) + } + } + if !md.CacheExpiry.IsZero() { + obj.Set("cacheExpiry", jsutil.TimeToDate(md.CacheExpiry)) + } + return obj +} diff --git a/cloudflare/r2/objects.go b/cloudflare/r2/objects.go new file mode 100644 index 0000000..1f18f48 --- /dev/null +++ b/cloudflare/r2/objects.go @@ -0,0 +1,44 @@ +package r2 + +import ( + "fmt" + "syscall/js" + + "github.com/syumai/workers/internal/jsutil" +) + +// Objects represents Cloudflare R2 objects. +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121 +type Objects struct { + Objects []*Object + Truncated bool + // Cursor indicates next cursor of Objects. + // - This becomes empty string if cursor doesn't exist. + Cursor string + DelimitedPrefixes []string +} + +// toObjects converts JavaScript side's Objects to *Objects. +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121 +func toObjects(v js.Value) (*Objects, error) { + objectsVal := v.Get("objects") + objects := make([]*Object, objectsVal.Length()) + for i := 0; i < len(objects); i++ { + obj, err := toObject(objectsVal.Index(i)) + if err != nil { + return nil, fmt.Errorf("error converting to Object: %w", err) + } + objects[i] = obj + } + prefixesVal := v.Get("delimitedPrefixes") + prefixes := make([]string, prefixesVal.Length()) + for i := 0; i < len(prefixes); i++ { + prefixes[i] = prefixesVal.Index(i).String() + } + return &Objects{ + Objects: objects, + Truncated: v.Get("truncated").Bool(), + Cursor: jsutil.MaybeString(v.Get("cursor")), + DelimitedPrefixes: prefixes, + }, nil +} diff --git a/cloudflare/r2bucket.go b/cloudflare/r2bucket.go index e617f52..9235e63 100644 --- a/cloudflare/r2bucket.go +++ b/cloudflare/r2bucket.go @@ -1,135 +1 @@ package cloudflare - -import ( - "fmt" - "io" - "syscall/js" - - "github.com/syumai/workers/cloudflare/internal/cfruntimecontext" - "github.com/syumai/workers/internal/jsutil" -) - -// R2Bucket represents interface of Cloudflare Worker's R2 Bucket instance. -// - https://developers.cloudflare.com/r2/runtime-apis/#bucket-method-definitions -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1006 -type R2Bucket struct { - instance js.Value -} - -// NewR2Bucket returns R2Bucket for given variable name. -// - variable name must be defined in wrangler.toml. -// - see example: https://github.com/syumai/workers/tree/main/_examples/r2-image-viewer -// - if the given variable name doesn't exist on runtime context, returns error. -// - This function panics when a runtime context is not found. -func NewR2Bucket(varName string) (*R2Bucket, error) { - inst := cfruntimecontext.MustGetRuntimeContextEnv().Get(varName) - if inst.IsUndefined() { - return nil, fmt.Errorf("%s is undefined", varName) - } - return &R2Bucket{instance: inst}, nil -} - -// Head returns the result of `head` call to R2Bucket. -// - Body field of *R2Object is always nil for Head call. -// - if the object for given key doesn't exist, returns nil. -// - if a network error happens, returns error. -func (r *R2Bucket) Head(key string) (*R2Object, error) { - p := r.instance.Call("head", key) - v, err := jsutil.AwaitPromise(p) - if err != nil { - return nil, err - } - if v.IsNull() { - return nil, nil - } - return toR2Object(v) -} - -// Get returns the result of `get` call to R2Bucket. -// - if the object for given key doesn't exist, returns nil. -// - if a network error happens, returns error. -func (r *R2Bucket) Get(key string) (*R2Object, error) { - p := r.instance.Call("get", key) - v, err := jsutil.AwaitPromise(p) - if err != nil { - return nil, err - } - if v.IsNull() { - return nil, nil - } - return toR2Object(v) -} - -// R2PutOptions represents Cloudflare R2 put options. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1128 -type R2PutOptions struct { - HTTPMetadata R2HTTPMetadata - CustomMetadata map[string]string - MD5 string -} - -func (opts *R2PutOptions) toJS() js.Value { - if opts == nil { - return js.Undefined() - } - obj := jsutil.NewObject() - if opts.HTTPMetadata != (R2HTTPMetadata{}) { - obj.Set("httpMetadata", opts.HTTPMetadata.toJS()) - } - if opts.CustomMetadata != nil { - // convert map[string]string to map[string]any. - // This makes the map convertible to JS. - // see: https://pkg.go.dev/syscall/js#ValueOf - customMeta := make(map[string]any, len(opts.CustomMetadata)) - for k, v := range opts.CustomMetadata { - customMeta[k] = v - } - obj.Set("customMetadata", customMeta) - } - if opts.MD5 != "" { - obj.Set("md5", opts.MD5) - } - return obj -} - -// Put returns the result of `put` call to R2Bucket. -// - This method copies all bytes into memory for implementation restriction. -// - Body field of *R2Object is always nil for Put call. -// - if a network error happens, returns error. -func (r *R2Bucket) Put(key string, value io.ReadCloser, opts *R2PutOptions) (*R2Object, error) { - // fetch body cannot be ReadableStream. see: https://github.com/whatwg/fetch/issues/1438 - b, err := io.ReadAll(value) - if err != nil { - return nil, err - } - defer value.Close() - ua := jsutil.NewUint8Array(len(b)) - js.CopyBytesToJS(ua, b) - p := r.instance.Call("put", key, ua.Get("buffer"), opts.toJS()) - v, err := jsutil.AwaitPromise(p) - if err != nil { - return nil, err - } - return toR2Object(v) -} - -// Delete returns the result of `delete` call to R2Bucket. -// - if a network error happens, returns error. -func (r *R2Bucket) Delete(key string) error { - p := r.instance.Call("delete", key) - if _, err := jsutil.AwaitPromise(p); err != nil { - return err - } - return nil -} - -// List returns the result of `list` call to R2Bucket. -// - if a network error happens, returns error. -func (r *R2Bucket) List() (*R2Objects, error) { - p := r.instance.Call("list") - v, err := jsutil.AwaitPromise(p) - if err != nil { - return nil, err - } - return toR2Objects(v) -} diff --git a/cloudflare/r2object.go b/cloudflare/r2object.go index d721240..9235e63 100644 --- a/cloudflare/r2object.go +++ b/cloudflare/r2object.go @@ -1,120 +1 @@ package cloudflare - -import ( - "errors" - "fmt" - "io" - "syscall/js" - "time" - - "github.com/syumai/workers/internal/jsutil" -) - -// R2Object represents Cloudflare R2 object. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094 -type R2Object struct { - instance js.Value - Key string - Version string - Size int - ETag string - HTTPETag string - Uploaded time.Time - HTTPMetadata R2HTTPMetadata - CustomMetadata map[string]string - // Body is a body of R2Object. - // This value is nil for the result of the `Head` or `Put` method. - Body io.Reader -} - -// TODO: implement -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1106 -// func (o *R2Object) WriteHTTPMetadata(headers http.Header) { -// } - -func (o *R2Object) BodyUsed() (bool, error) { - v := o.instance.Get("bodyUsed") - if v.IsUndefined() { - return false, errors.New("bodyUsed doesn't exist for this R2Object") - } - return v.Bool(), nil -} - -// toR2Object converts JavaScript side's R2Object to *R2Object. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094 -func toR2Object(v js.Value) (*R2Object, error) { - uploaded, err := jsutil.DateToTime(v.Get("uploaded")) - if err != nil { - return nil, fmt.Errorf("error converting uploaded: %w", err) - } - r2Meta, err := toR2HTTPMetadata(v.Get("httpMetadata")) - if err != nil { - return nil, fmt.Errorf("error converting httpMetadata: %w", err) - } - bodyVal := v.Get("body") - var body io.Reader - if !bodyVal.IsUndefined() { - body = jsutil.ConvertReadableStreamToReadCloser(v.Get("body")) - } - return &R2Object{ - instance: v, - Key: v.Get("key").String(), - Version: v.Get("version").String(), - Size: v.Get("size").Int(), - ETag: v.Get("etag").String(), - HTTPETag: v.Get("httpEtag").String(), - Uploaded: uploaded, - HTTPMetadata: r2Meta, - CustomMetadata: jsutil.StrRecordToMap(v.Get("customMetadata")), - Body: body, - }, nil -} - -// R2HTTPMetadata represents metadata of R2Object. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1053 -type R2HTTPMetadata struct { - ContentType string - ContentLanguage string - ContentDisposition string - ContentEncoding string - CacheControl string - CacheExpiry time.Time -} - -func toR2HTTPMetadata(v js.Value) (R2HTTPMetadata, error) { - if v.IsUndefined() || v.IsNull() { - return R2HTTPMetadata{}, nil - } - cacheExpiry, err := jsutil.MaybeDate(v.Get("cacheExpiry")) - if err != nil { - return R2HTTPMetadata{}, fmt.Errorf("error converting cacheExpiry: %w", err) - } - return R2HTTPMetadata{ - ContentType: jsutil.MaybeString(v.Get("contentType")), - ContentLanguage: jsutil.MaybeString(v.Get("contentLanguage")), - ContentDisposition: jsutil.MaybeString(v.Get("contentDisposition")), - ContentEncoding: jsutil.MaybeString(v.Get("contentEncoding")), - CacheControl: jsutil.MaybeString(v.Get("cacheControl")), - CacheExpiry: cacheExpiry, - }, nil -} - -func (md *R2HTTPMetadata) toJS() js.Value { - obj := jsutil.NewObject() - kv := map[string]string{ - "contentType": md.ContentType, - "contentLanguage": md.ContentLanguage, - "contentDisposition": md.ContentDisposition, - "contentEncoding": md.ContentEncoding, - "cacheControl": md.CacheControl, - } - for k, v := range kv { - if v != "" { - obj.Set(k, v) - } - } - if !md.CacheExpiry.IsZero() { - obj.Set("cacheExpiry", jsutil.TimeToDate(md.CacheExpiry)) - } - return obj -} diff --git a/cloudflare/r2objects.go b/cloudflare/r2objects.go index 4d0302b..9235e63 100644 --- a/cloudflare/r2objects.go +++ b/cloudflare/r2objects.go @@ -1,44 +1 @@ package cloudflare - -import ( - "fmt" - "syscall/js" - - "github.com/syumai/workers/internal/jsutil" -) - -// R2Objects represents Cloudflare R2 objects. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121 -type R2Objects struct { - Objects []*R2Object - Truncated bool - // Cursor indicates next cursor of R2Objects. - // - This becomes empty string if cursor doesn't exist. - Cursor string - DelimitedPrefixes []string -} - -// toR2Objects converts JavaScript side's R2Objects to *R2Objects. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121 -func toR2Objects(v js.Value) (*R2Objects, error) { - objectsVal := v.Get("objects") - objects := make([]*R2Object, objectsVal.Length()) - for i := 0; i < len(objects); i++ { - obj, err := toR2Object(objectsVal.Index(i)) - if err != nil { - return nil, fmt.Errorf("error converting to R2Object: %w", err) - } - objects[i] = obj - } - prefixesVal := v.Get("delimitedPrefixes") - prefixes := make([]string, prefixesVal.Length()) - for i := 0; i < len(prefixes); i++ { - prefixes[i] = prefixesVal.Index(i).String() - } - return &R2Objects{ - Objects: objects, - Truncated: v.Get("truncated").Bool(), - Cursor: jsutil.MaybeString(v.Get("cursor")), - DelimitedPrefixes: prefixes, - }, nil -}