Skip to content

Commit 7ef2b12

Browse files
petfoldclaude
andcommitted
feat(api): add server-side manifest listing endpoint
Add GET /manifest/{address}/{path}, which walks the Mantaray trie server-side and returns entries as JSON, so clients no longer need to fetch and traverse the trie chunk by chunk just to list a collection. Semantics follow S3 ListObjectsV2: optional path prefix, an optional delimiter for shallow (pseudo-directory) listings, lexicographic pagination via limit/after, and opt-in per-entry sizes. - pkg/manifest: add the optional EntryWalker interface (+ ErrStopWalk) and implement WalkEntry on the mantaray manifest over the existing sorted WalkNode, without expanding the core Interface. - pkg/api: manifestListHandler + route, reusing the /bzz manifest resolution and ACT decryption chain; per-entry size via the root chunk span. - openapi: document the path plus ManifestList/ManifestListEntry schemas. - tests: recursive, delimiter, prefix, pagination and 404 cases over a real mantaray fixture, including the empty root-metadata entry. Refs #5535 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 743ce16 commit 7ef2b12

7 files changed

Lines changed: 555 additions & 0 deletions

File tree

openapi/Swarm.yaml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,77 @@ paths:
536536
default:
537537
description: Default response
538538

539+
"/manifest/{address}/{prefix}":
540+
get:
541+
summary: "List the contents of a manifest (server-side trie walk)"
542+
description: >
543+
Enumerate the entries of a manifest without downloading and traversing
544+
the Mantaray trie client-side. Semantics follow S3 ListObjectsV2:
545+
optional path prefix, an optional delimiter for shallow
546+
(pseudo-directory) listings, and lexicographic pagination via
547+
`limit`/`after`. See ethersphere/bee#5535.
548+
tags:
549+
- BZZ
550+
parameters:
551+
- in: path
552+
name: address
553+
schema:
554+
$ref: "SwarmCommon.yaml#/components/schemas/SwarmReference"
555+
required: true
556+
description: Swarm address of the manifest.
557+
- in: path
558+
name: prefix
559+
schema:
560+
type: string
561+
required: false
562+
description: Path prefix to list under (default is the manifest root).
563+
- in: query
564+
name: delimiter
565+
schema:
566+
type: string
567+
required: false
568+
description: >
569+
If set (typically `/`), return a shallow listing: entries directly
570+
under the prefix plus `commonPrefixes` for deeper paths. If unset,
571+
the listing is recursive.
572+
- in: query
573+
name: limit
574+
schema:
575+
type: integer
576+
default: 1000
577+
required: false
578+
description: Maximum number of entries (plus common prefixes) per page. Node-enforced hard cap.
579+
- in: query
580+
name: after
581+
schema:
582+
type: string
583+
required: false
584+
description: Continuation token; return entries strictly after this path.
585+
- in: query
586+
name: sizes
587+
schema:
588+
type: boolean
589+
default: false
590+
required: false
591+
description: >
592+
If true, resolve each entry's byte length from its root-chunk span
593+
(one extra chunk read per entry).
594+
responses:
595+
"200":
596+
description: OK
597+
content:
598+
application/json:
599+
schema:
600+
$ref: "SwarmCommon.yaml#/components/schemas/ManifestList"
601+
"400":
602+
$ref: "SwarmCommon.yaml#/components/responses/400"
603+
"404":
604+
$ref: "SwarmCommon.yaml#/components/responses/404"
605+
"500":
606+
$ref: "SwarmCommon.yaml#/components/responses/500"
607+
default:
608+
description: Default response
609+
539610
"/tags":
540611
get:
541612
summary: Get list of tags

openapi/SwarmCommon.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,40 @@ components:
726726
- $ref: "#/components/schemas/SwarmEncryptedReference"
727727
- $ref: "#/components/schemas/DomainName"
728728

729+
ManifestListEntry:
730+
type: object
731+
properties:
732+
path:
733+
type: string
734+
reference:
735+
$ref: "#/components/schemas/SwarmReference"
736+
metadata:
737+
type: object
738+
additionalProperties:
739+
type: string
740+
size:
741+
type: integer
742+
description: Present only when the request set sizes=true and the span could be read.
743+
744+
ManifestList:
745+
type: object
746+
properties:
747+
entries:
748+
type: array
749+
items:
750+
$ref: "#/components/schemas/ManifestListEntry"
751+
commonPrefixes:
752+
type: array
753+
description: Pseudo-directory prefixes; present only when a delimiter was supplied.
754+
items:
755+
type: string
756+
truncated:
757+
type: boolean
758+
description: True when more entries remain beyond this page.
759+
nextMarker:
760+
type: string
761+
description: Continuation token to pass as `after` for the next page.
762+
729763
SwapCashoutResult:
730764
type: object
731765
properties:

pkg/api/manifest.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright 2026 The Swarm Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package api
6+
7+
import (
8+
"context"
9+
"encoding/binary"
10+
"errors"
11+
"net/http"
12+
"strings"
13+
14+
"github.com/ethersphere/bee/v2/pkg/file/loadsave"
15+
"github.com/ethersphere/bee/v2/pkg/file/redundancy"
16+
"github.com/ethersphere/bee/v2/pkg/jsonhttp"
17+
"github.com/ethersphere/bee/v2/pkg/manifest"
18+
"github.com/ethersphere/bee/v2/pkg/swarm"
19+
"github.com/ethersphere/bee/v2/pkg/tracing"
20+
"github.com/gorilla/mux"
21+
)
22+
23+
const (
24+
manifestListDefaultLimit = 1000
25+
manifestListMaxLimit = 10000
26+
)
27+
28+
// emptyManifestEntry is the reference bee's uploader stores for manifest-level
29+
// metadata held at RootPath (e.g. website-index-document): 32 zero bytes. Note
30+
// this is NOT swarm.ZeroAddress (which has nil bytes), so Address.IsZero() does
31+
// not match it — mirror the convention used by mantarayManifest.IterateAddresses.
32+
var emptyManifestEntry = swarm.NewAddress([]byte{31: 0})
33+
34+
type ManifestListEntry struct {
35+
Path string `json:"path"`
36+
Reference swarm.Address `json:"reference"`
37+
Metadata map[string]string `json:"metadata,omitempty"`
38+
Size *int64 `json:"size,omitempty"`
39+
}
40+
41+
type ManifestListResponse struct {
42+
Entries []ManifestListEntry `json:"entries"`
43+
CommonPrefixes []string `json:"commonPrefixes,omitempty"`
44+
Truncated bool `json:"truncated"`
45+
NextMarker string `json:"nextMarker,omitempty"`
46+
}
47+
48+
// manifestListHandler serves a server-side listing of a manifest's contents,
49+
// walking the Mantaray trie on the node instead of forcing clients to fetch
50+
// and traverse it chunk by chunk. Semantics mirror S3 ListObjectsV2 (prefix,
51+
// delimiter, pagination) — see ethersphere/bee#5535.
52+
func (s *Service) manifestListHandler(w http.ResponseWriter, r *http.Request) {
53+
logger := tracing.NewLoggerWithTraceID(r.Context(), s.logger.WithName("get_manifest").Build())
54+
55+
paths := struct {
56+
Address swarm.Address `map:"address,resolve" validate:"required"`
57+
Prefix string `map:"path"`
58+
}{}
59+
if response := s.mapStructure(mux.Vars(r), &paths); response != nil {
60+
response("invalid path params", logger, w)
61+
return
62+
}
63+
64+
queries := struct {
65+
Delimiter string `map:"delimiter"`
66+
Limit int `map:"limit"`
67+
After string `map:"after"`
68+
Sizes bool `map:"sizes"`
69+
}{
70+
Limit: manifestListDefaultLimit,
71+
}
72+
if response := s.mapStructure(r.URL.Query(), &queries); response != nil {
73+
response("invalid query params", logger, w)
74+
return
75+
}
76+
// enforce the node-side hard cap; a non-positive limit falls back to it too.
77+
if queries.Limit <= 0 || queries.Limit > manifestListMaxLimit {
78+
queries.Limit = manifestListMaxLimit
79+
}
80+
81+
address := paths.Address
82+
if v := getAddressFromContext(r.Context()); !v.IsZero() {
83+
address = v
84+
}
85+
86+
ctx := r.Context()
87+
ls := loadsave.NewReadonly(s.storer.Download(true), s.storer.Cache(), redundancy.DefaultDownloadLevel)
88+
89+
m, err := manifest.NewDefaultManifestReference(address, ls)
90+
if err != nil {
91+
logger.Debug("manifest list: not a manifest", "address", address, "error", err)
92+
logger.Error(nil, "manifest list: not a manifest")
93+
jsonhttp.NotFound(w, nil)
94+
return
95+
}
96+
97+
walker, ok := m.(manifest.EntryWalker)
98+
if !ok {
99+
logger.Error(nil, "manifest list: manifest type does not support listing")
100+
jsonhttp.InternalServerError(w, "manifest listing not supported")
101+
return
102+
}
103+
104+
prefix := paths.Prefix
105+
resp := ManifestListResponse{Entries: []ManifestListEntry{}}
106+
seenPrefix := make(map[string]struct{})
107+
// marker tracks the last path fully consumed on this page; on truncation it
108+
// becomes nextMarker so a resumed page starts strictly after it. It is only
109+
// advanced for paths that are actually accounted for (emitted or folded into
110+
// a common prefix), never for entries skipped by the `after` continuation.
111+
var marker string
112+
113+
walkErr := walker.WalkEntry(ctx, prefix, func(p string, entry manifest.Entry) error {
114+
// skip the synthetic root-metadata entry (empty reference); its
115+
// manifest-level metadata (e.g. website-index-document) is not a file.
116+
if entry.Reference().IsZero() || entry.Reference().Equal(emptyManifestEntry) {
117+
return nil
118+
}
119+
// continuation token: only paths strictly after it are unseen.
120+
if queries.After != "" && p <= queries.After {
121+
return nil
122+
}
123+
124+
if queries.Delimiter != "" {
125+
rest := strings.TrimPrefix(p, prefix)
126+
if idx := strings.Index(rest, queries.Delimiter); idx >= 0 {
127+
cp := prefix + rest[:idx+len(queries.Delimiter)]
128+
if _, dup := seenPrefix[cp]; dup {
129+
// already an open common prefix — folding p in is free.
130+
marker = p
131+
return nil
132+
}
133+
if len(resp.Entries)+len(resp.CommonPrefixes) >= queries.Limit {
134+
resp.Truncated = true
135+
resp.NextMarker = marker
136+
return manifest.ErrStopWalk
137+
}
138+
seenPrefix[cp] = struct{}{}
139+
resp.CommonPrefixes = append(resp.CommonPrefixes, cp)
140+
marker = p
141+
return nil
142+
}
143+
}
144+
145+
if len(resp.Entries)+len(resp.CommonPrefixes) >= queries.Limit {
146+
resp.Truncated = true
147+
resp.NextMarker = marker
148+
return manifest.ErrStopWalk
149+
}
150+
151+
le := ManifestListEntry{
152+
Path: p,
153+
Reference: entry.Reference(),
154+
Metadata: entry.Metadata(),
155+
}
156+
if queries.Sizes {
157+
if size, err := s.manifestEntrySize(ctx, entry.Reference()); err == nil {
158+
le.Size = &size
159+
} else {
160+
// size is best-effort: a legacy/unreachable entry still lists.
161+
logger.Debug("manifest list: size resolution failed", "path", p, "error", err)
162+
}
163+
}
164+
resp.Entries = append(resp.Entries, le)
165+
marker = p
166+
return nil
167+
})
168+
if walkErr != nil {
169+
if errors.Is(walkErr, manifest.ErrNotFound) {
170+
logger.Debug("manifest list: prefix not found", "address", address, "prefix", prefix)
171+
jsonhttp.NotFound(w, "prefix not found")
172+
return
173+
}
174+
// a partially-retrievable manifest is reported as an error rather than
175+
// silently returning a truncated view (see #5535 error semantics).
176+
logger.Debug("manifest list: walk failed", "address", address, "error", walkErr)
177+
logger.Error(nil, "manifest list: walk failed")
178+
jsonhttp.NotFound(w, "manifest incomplete or not retrievable")
179+
return
180+
}
181+
182+
jsonhttp.OK(w, resp)
183+
}
184+
185+
// manifestEntrySize resolves a file entry's byte length by reading the 8-byte
186+
// span header from its root chunk. Opt-in (sizes=true): one extra chunk read
187+
// per entry.
188+
func (s *Service) manifestEntrySize(ctx context.Context, ref swarm.Address) (int64, error) {
189+
ch, err := s.storer.Download(true).Get(ctx, ref)
190+
if err != nil {
191+
return 0, err
192+
}
193+
data := ch.Data()
194+
if len(data) < swarm.SpanSize {
195+
return 0, errors.New("chunk shorter than span")
196+
}
197+
return int64(binary.LittleEndian.Uint64(data[:swarm.SpanSize])), nil
198+
}

0 commit comments

Comments
 (0)