aboutsummaryrefslogtreecommitdiff
path: root/internal/bunny/api_list.go
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2026-07-06 16:52:20 +0200
committertsne <tsne.dev@outlook.com>2026-07-06 21:37:42 +0200
commit84da43998f7cc42cc72652333ac6e3d293abb23e (patch)
tree6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/bunny/api_list.go
downloadhopper-main.tar.gz
initialHEADmain
Diffstat (limited to 'internal/bunny/api_list.go')
-rw-r--r--internal/bunny/api_list.go152
1 files changed, 152 insertions, 0 deletions
diff --git a/internal/bunny/api_list.go b/internal/bunny/api_list.go
new file mode 100644
index 0000000..57a7817
--- /dev/null
+++ b/internal/bunny/api_list.go
@@ -0,0 +1,152 @@
+package bunny
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "tsne.dev/hopper/internal/log"
+)
+
+// Iterator is an old-school pull iterator over the recursive remote listing under
+// a starting directory. It yields every entry (files AND directories; a
+// directory has Size 0) so consumers can build whatever structure they need:
+// push collects files and verifies no file collides with a remote directory;
+// prune derives orphans and empty directories. Usage:
+//
+// it := client.List(ctx, dir)
+// for it.Next() {
+// rec := it.Object()
+// }
+// if err := it.Err(); err != nil { ... }
+//
+// The walk is iterative: a stack holds directories still to list, each listed
+// on demand. A 404 listing is an empty subtree (skipped, not fatal); any other
+// listing failure stops iteration and surfaces via Err.
+type Iterator struct {
+ ctx context.Context
+ listDir func(context.Context, string) ([]Object, error)
+ stack []string
+ pending []Object
+ cur Object
+ err error
+ done bool
+}
+
+// NewIterator builds an Iterator rooted at dir, listing each directory via listDir.
+func NewIterator(ctx context.Context, dir string, listDir func(context.Context, string) ([]Object, error)) *Iterator {
+ stack := make([]string, 0, 4)
+ stack = append(stack, dir)
+ return &Iterator{ctx: ctx, listDir: listDir, stack: stack}
+}
+
+// Next advances to the next entry, returning false when the walk is exhausted
+// or a listing failed (check Err). Directory entries are yielded and also queued
+// for descent.
+func (it *Iterator) Next() bool {
+ if it.err != nil || it.done {
+ return false
+ }
+ for {
+ if len(it.pending) > 0 {
+ o := it.pending[0]
+ it.pending = it.pending[1:]
+ if o.IsDirectory {
+ it.stack = append(it.stack, o.Path)
+ }
+ it.cur = o
+ return true
+ }
+ if len(it.stack) == 0 {
+ it.done = true
+ return false
+ }
+ dir := it.stack[len(it.stack)-1]
+ it.stack = it.stack[:len(it.stack)-1]
+ objs, err := it.listDir(it.ctx, dir)
+ if err != nil {
+ var apiErr *apiError
+ if errors.As(err, &apiErr) && apiErr.status == http.StatusNotFound {
+ continue // 404 = empty subtree
+ }
+ it.err = err
+ return false
+ }
+ it.pending = objs
+ }
+}
+
+// Object returns the entry reached by the most recent Next.
+func (it *Iterator) Object() Object {
+ return it.cur
+}
+
+// Err returns the listing failure that stopped iteration, if any.
+func (it *Iterator) Err() error {
+ return it.err
+}
+
+// List returns an Iterator over the recursive listing rooted at dir. An empty
+// dir denotes zone root.
+func (api *API) List(ctx context.Context, dir string) *Iterator {
+ return NewIterator(ctx, dir, api.listDir)
+}
+
+// listDir returns the immediate children of dir. Bunny's list endpoint is
+// per-directory and expects a trailing slash on the directory URL. Returned
+// Object.Path is the full storage key relative to the zone (matching source
+// keys), so the skip-check can match it.
+func (api *API) listDir(ctx context.Context, dir string) ([]Object, error) {
+ if dir == "" {
+ log.PrintVerbose("listing zone root")
+ } else {
+ log.PrintVerbose("listing %s", dir)
+ }
+
+ u := api.url(dir)
+ if !strings.HasSuffix(u, "/") {
+ u += "/"
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+
+ resp, err := api.do(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var raw []listObject
+ if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
+ return nil, newAPIError(errorKindInternal, errors.New("failed to decode list response ("+err.Error()+")"), req, resp.StatusCode)
+ }
+
+ prefix := "/" + api.zone + "/"
+ objs := make([]Object, len(raw))
+ for i, o := range raw {
+ objs[i] = Object{
+ Path: strings.TrimPrefix(o.Path, prefix) + o.ObjectName,
+ Size: o.Length,
+ IsDirectory: o.IsDirectory,
+ LastChanged: parseTime(o.LastChanged),
+ }
+ }
+ return objs, nil
+}
+
+// listObject mirrors a single entry of Bunny's per-directory list response. The
+// full storage key is Path+ObjectName (Path includes the zone and trailing
+// slash); see List.
+type listObject struct {
+ Path string `json:"Path"`
+ ObjectName string `json:"ObjectName"`
+ Length int `json:"Length"`
+ LastChanged string `json:"LastChanged"`
+ IsDirectory bool `json:"IsDirectory"`
+}