diff options
Diffstat (limited to 'internal/bunny/api_list.go')
| -rw-r--r-- | internal/bunny/api_list.go | 152 |
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"` +} |