From 220a690c163bda8021180a791036d3fb716398a8 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Fri, 29 Aug 2025 14:28:38 +0200 Subject: [PATCH 01/17] Unexport private constant I see no reason for it to be exported. --- routing/http/server/server.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 7e96d58f2..9e9c094bb 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -50,7 +50,7 @@ const ( providePath = "/routing/v1/providers/" findProvidersPath = "/routing/v1/providers/{cid}" findPeersPath = "/routing/v1/peers/{peer-id}" - GetIPNSPath = "/routing/v1/ipns/{cid}" + getIPNSPath = "/routing/v1/ipns/{cid}" ) type FindProvidersAsyncResponse struct { @@ -181,8 +181,8 @@ func Handler(svc ContentRouter, opts ...Option) http.Handler { r.Handle(findProvidersPath, middlewarestd.Handler(findProvidersPath, mdlw, http.HandlerFunc(server.findProviders))).Methods(http.MethodGet) r.Handle(providePath, middlewarestd.Handler(providePath, mdlw, http.HandlerFunc(server.provide))).Methods(http.MethodPut) r.Handle(findPeersPath, middlewarestd.Handler(findPeersPath, mdlw, http.HandlerFunc(server.findPeers))).Methods(http.MethodGet) - r.Handle(GetIPNSPath, middlewarestd.Handler(GetIPNSPath, mdlw, http.HandlerFunc(server.GetIPNS))).Methods(http.MethodGet) - r.Handle(GetIPNSPath, middlewarestd.Handler(GetIPNSPath, mdlw, http.HandlerFunc(server.PutIPNS))).Methods(http.MethodPut) + r.Handle(getIPNSPath, middlewarestd.Handler(getIPNSPath, mdlw, http.HandlerFunc(server.GetIPNS))).Methods(http.MethodGet) + r.Handle(getIPNSPath, middlewarestd.Handler(getIPNSPath, mdlw, http.HandlerFunc(server.PutIPNS))).Methods(http.MethodPut) return r } From f7788a75fcdb6caa0b4cc2765d78302e75257fce Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Mon, 1 Sep 2025 13:42:49 +0200 Subject: [PATCH 02/17] routing/http/server: add support for GetClosestPeers This adds the SERVER-side for GetClosestPeers. Since FindPeers also returns PeerRecords, it is essentially a copy-paste, minus things like addrFilters which don't apply here, plus `count` and `closerThan` parsing from the query URL. The tests as well. We leave all logic regarding count/closerThan to the ContentRouter (the DHT, or the Kubo wrapper around it). Spec: https://github.com/ipfs/specs/pull/476 --- routing/http/server/server.go | 149 ++++++++++--- routing/http/server/server_test.go | 346 +++++++++++++++++++++++++++++ 2 files changed, 467 insertions(+), 28 deletions(-) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 9e9c094bb..b7f14247f 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -9,6 +9,7 @@ import ( "io" "mime" "net/http" + "strconv" "strings" "sync/atomic" "time" @@ -42,15 +43,17 @@ const ( DefaultRecordsLimit = 20 DefaultStreamingRecordsLimit = 0 DefaultRoutingTimeout = 30 * time.Second + DefaultGetClosestPeersCount = 20 ) var logger = logging.Logger("routing/http/server") const ( - providePath = "/routing/v1/providers/" - findProvidersPath = "/routing/v1/providers/{cid}" - findPeersPath = "/routing/v1/peers/{peer-id}" - getIPNSPath = "/routing/v1/ipns/{cid}" + providePath = "/routing/v1/providers/" + findProvidersPath = "/routing/v1/providers/{cid}" + findPeersPath = "/routing/v1/peers/{peer-id}" + getIPNSPath = "/routing/v1/ipns/{cid}" + getClosestPeersPath = "/routing/v1/dht/closest/peers/{peer-id}" ) type FindProvidersAsyncResponse struct { @@ -78,6 +81,10 @@ type ContentRouter interface { // PutIPNS stores the provided [ipns.Record] for the given [ipns.Name]. // It is guaranteed that the record matches the provided name. PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error + + // GetClosestPeers returns the DHT closest peers to the given peer ID. + // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). + GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) } // Deprecated: protocol-agnostic provide is being worked on in [IPIP-378]: @@ -183,6 +190,7 @@ func Handler(svc ContentRouter, opts ...Option) http.Handler { r.Handle(findPeersPath, middlewarestd.Handler(findPeersPath, mdlw, http.HandlerFunc(server.findPeers))).Methods(http.MethodGet) r.Handle(getIPNSPath, middlewarestd.Handler(getIPNSPath, mdlw, http.HandlerFunc(server.GetIPNS))).Methods(http.MethodGet) r.Handle(getIPNSPath, middlewarestd.Handler(getIPNSPath, mdlw, http.HandlerFunc(server.PutIPNS))).Methods(http.MethodPut) + r.Handle(getClosestPeersPath, middlewarestd.Handler(getClosestPeersPath, mdlw, http.HandlerFunc(server.getClosestPeers))).Methods(http.MethodGet) return r } @@ -313,30 +321,7 @@ func (s *server) findProvidersNDJSON(w http.ResponseWriter, provIter iter.Result func (s *server) findPeers(w http.ResponseWriter, r *http.Request) { pidStr := mux.Vars(r)["peer-id"] - - // While specification states that peer-id is expected to be in CIDv1 format, reality - // is the clients will often learn legacy PeerID string from other sources, - // and try to use it. - // See https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation - // We are liberal in inputs here, and uplift legacy PeerID to CID if necessary. - // Rationale: it is better to fix this common mistake than to error and break peer routing. - - // Attempt to parse PeerID - pid, err := peer.Decode(pidStr) - if err != nil { - // Retry by parsing PeerID as CID, then setting codec to libp2p-key - // and turning that back to PeerID. - // This is necessary to make sure legacy keys like: - // - RSA QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N - // - ED25519 12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA - // are parsed correctly. - pidAsCid, err2 := cid.Decode(pidStr) - if err2 == nil { - pidAsCid = cid.NewCidV1(cid.Libp2pKey, pidAsCid.Hash()) - pid, err = peer.FromCid(pidAsCid) - } - } - + pid, err := parsePeerID(pidStr) if err != nil { writeErr(w, "FindPeers", http.StatusBadRequest, fmt.Errorf("unable to parse PeerID %q: %w", pidStr, err)) return @@ -608,6 +593,88 @@ func (s *server) PutIPNS(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { + pidStr := mux.Vars(r)["peer-id"] + pid, err := parsePeerID(pidStr) + if err != nil { + writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse PeerID %q: %w", pidStr, err)) + return + } + + query := r.URL.Query() + closerThanStr := query.Get("closerThan") + var closerThanPid peer.ID + if closerThanStr != "" { // it is fine to omit. We will pass an empty peer.ID then. + closerThanPid, err = parsePeerID(closerThanStr) + if err != nil { + writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse closer-than PeerID %q: %w", pidStr, err)) + return + } + } + + countStr := query.Get("count") + count, err := strconv.Atoi(countStr) + if err != nil { + count = 0 + } + if count > 100 { + count = 100 + } + // If limit is still 0, set THE default. + if count <= 0 { + count = DefaultGetClosestPeersCount + } + + mediaType, err := s.detectResponseType(r) + if err != nil { + writeErr(w, "GetClosestPeers", http.StatusBadRequest, err) + return + } + + var ( + handlerFunc func(w http.ResponseWriter, provIter iter.ResultIter[*types.PeerRecord]) + ) + + if mediaType == mediaTypeNDJSON { + handlerFunc = s.getClosestPeersNDJSON + } else { + handlerFunc = s.getClosestPeersJSON + } + + // Add timeout to the routing operation + ctx, cancel := context.WithTimeout(r.Context(), s.routingTimeout) + defer cancel() + + provIter, err := s.svc.GetClosestPeers(ctx, pid, closerThanPid, count) + if err != nil { + if errors.Is(err, routing.ErrNotFound) { + // handlerFunc takes care of setting the 404 and necessary headers + provIter = iter.FromSlice([]iter.Result[*types.PeerRecord]{}) + } else { + writeErr(w, "GetClosestPeers", http.StatusInternalServerError, fmt.Errorf("delegate error: %w", err)) + return + } + } + handlerFunc(w, provIter) +} + +func (s *server) getClosestPeersJSON(w http.ResponseWriter, peersIter iter.ResultIter[*types.PeerRecord]) { + defer peersIter.Close() + peers, err := iter.ReadAllResults(peersIter) + if err != nil { + writeErr(w, "GetClosestPeers", http.StatusInternalServerError, fmt.Errorf("delegate error: %w", err)) + return + } + + writeJSONResult(w, "FindPeers", jsontypes.PeersResponse{ + Peers: peers, + }) +} + +func (s *server) getClosestPeersNDJSON(w http.ResponseWriter, peersIter iter.ResultIter[*types.PeerRecord]) { + writeResultsIterNDJSON(w, peersIter) +} + var ( // Rule-of-thumb Cache-Control policy is to work well with caching proxies and load balancers. // If there are any results, cache on the client for longer, and hint any in-between caches to @@ -618,6 +685,32 @@ var ( maxStale = int((48 * time.Hour).Seconds()) // allow stale results as long within Amino DHT Expiration window ) +func parsePeerID(pidStr string) (peer.ID, error) { + // While specification states that peer-id is expected to be in CIDv1 format, reality + // is the clients will often learn legacy PeerID string from other sources, + // and try to use it. + // See https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + // We are liberal in inputs here, and uplift legacy PeerID to CID if necessary. + // Rationale: it is better to fix this common mistake than to error and break peer routing. + + // Attempt to parse PeerID + pid, err := peer.Decode(pidStr) + if err != nil { + // Retry by parsing PeerID as CID, then setting codec to libp2p-key + // and turning that back to PeerID. + // This is necessary to make sure legacy keys like: + // - RSA QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N + // - ED25519 12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA + // are parsed correctly. + pidAsCid, err2 := cid.Decode(pidStr) + if err2 == nil { + pidAsCid = cid.NewCidV1(cid.Libp2pKey, pidAsCid.Hash()) + pid, err = peer.FromCid(pidAsCid) + } + } + return pid, err +} + func setCacheControl(w http.ResponseWriter, maxAge int, stale int) { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d, stale-if-error=%d", maxAge, stale, stale)) } diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index 29e259925..a3e25f9f9 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -668,6 +668,343 @@ func TestPeers(t *testing.T) { } } +func TestGetClosestPeers(t *testing.T) { + makeRequest := func(t *testing.T, router *mockContentRouter, contentType, arg, closerThan, count string) *http.Response { + server := httptest.NewServer(Handler(router)) + t.Cleanup(server.Close) + + urlStr := fmt.Sprintf("http://%s/routing/v1/dht/closest/peers/%s?closerThan=%s&count=%s", server.Listener.Addr().String(), arg, closerThan, count) + t.Log(urlStr) + + req, err := http.NewRequest(http.MethodGet, urlStr, nil) + require.NoError(t, err) + if contentType != "" { + req.Header.Set("Accept", contentType) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp + } + + t.Run("GET /routing/v1/dht/closest/peers/{non-peerid} returns 400", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + resp := makeRequest(t, router, mediaTypeJSON, "nonpeerid", "", "") + require.Equal(t, 400, resp.StatusCode) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, explicit JSON)", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + + resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), "", "") + require.Equal(t, 404, resp.StatusCode) + + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + requireCloseToNow(t, resp.Header.Get("Last-Modified")) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?count=? is bewteen[0-100] per spec", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + + testCases := []struct { + value string + expected int + }{ + {"", 20}, + {"0", 20}, + {"55", 55}, + {"110", 100}, + {"-5", 20}, + {"abc", 20}, + } + + for _, tc := range testCases { + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-bitswap", "transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + }) + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), tc.expected).Return(results, nil) + resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), "", tc.value) + require.Equal(t, 200, resp.StatusCode) + } + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?closerThan=? gets parsed or set to empty peer.ID", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + _, closerThanPid := makeEd25519PeerID(t) + + testCases := []struct { + value string + expected peer.ID + }{ + {"", peer.ID("")}, + {peer.ToCid(closerThanPid).String(), closerThanPid}, + } + + for _, tc := range testCases { + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-bitswap", "transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + }) + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, tc.expected, 20).Return(results, nil) + resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), tc.value, "") + require.Equal(t, 200, resp.StatusCode) + } + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + + // Simulate request with Accept header that includes wildcard match + resp := makeRequest(t, router, "text/html,*/*", peer.ToCid(pid).String(), "", "") + + // Expect response to default to application/json + require.Equal(t, 404, resp.StatusCode) + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, implicit JSON, no Accept header)", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + + // Simulate request without Accept header + resp := makeRequest(t, router, "", peer.ToCid(pid).String(), "", "") + + // Expect response to default to application/json + require.Equal(t, 404, resp.StatusCode) + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 when router returns routing.ErrNotFound", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(nil, routing.ErrNotFound) + + // Simulate request without Accept header + resp := makeRequest(t, router, "", peer.ToCid(pid).String(), "", "") + + // Expect response to default to application/json + require.Equal(t, 404, resp.StatusCode) + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (JSON)", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-bitswap", "transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + }) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + + libp2pKeyCID := peer.ToCid(pid).String() + resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID, "", "") + require.Equal(t, 200, resp.StatusCode) + + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + requireCloseToNow(t, resp.Header.Get("Last-Modified")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedBody := `{"Peers":[{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"},{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}]}` + require.Equal(t, expectedBody, string(body)) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, NDJSON)", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + + resp := makeRequest(t, router, mediaTypeNDJSON, peer.ToCid(pid).String(), "", "") + require.Equal(t, 404, resp.StatusCode) + + require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + requireCloseToNow(t, resp.Header.Get("Last-Modified")) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (NDJSON)", func(t *testing.T) { + t.Parallel() + + _, pid := makeEd25519PeerID(t) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-bitswap", "transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + }) + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + + libp2pKeyCID := peer.ToCid(pid).String() + resp := makeRequest(t, router, mediaTypeNDJSON, libp2pKeyCID, "", "") + require.Equal(t, 200, resp.StatusCode) + + require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedBody := `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"}` + "\n" + `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}` + "\n" + require.Equal(t, expectedBody, string(body)) + }) + + // Test matrix that runs the HTTP 200 scenario against different flavours of PeerID + // to ensure consistent behavior + peerIdtestCases := []struct { + peerIdType string + makePeerId func(t *testing.T) (crypto.PrivKey, peer.ID) + peerIdAsCidV1 bool + }{ + // Test against current and past PeerID key types and string representations. + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + {"cidv1-libp2p-key-ed25519-peerid", makeEd25519PeerID, true}, + {"base58-ed25519-peerid", makeEd25519PeerID, false}, + {"cidv1-libp2p-key-rsa-peerid", makeLegacyRSAPeerID, true}, + {"base58-rsa-peerid", makeLegacyRSAPeerID, false}, + } + + for _, tc := range peerIdtestCases { + _, pid := tc.makePeerId(t) + var peerIDStr string + if tc.peerIdAsCidV1 { + // PeerID represented by CIDv1 with libp2p-key codec + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + peerIDStr = peer.ToCid(pid).String() + } else { + // Legacy PeerID starting with "123..." or "Qm.." + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + peerIDStr = b58.Encode([]byte(pid)) + } + results := []iter.Result[*types.PeerRecord]{ + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-bitswap", "transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + } + + t.Run("GET /routing/v1/dht/closest/peers/{"+tc.peerIdType+"} returns 200 with correct body and headers (NDJSON streaming response)", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(iter.FromSlice(results), nil) + + resp := makeRequest(t, router, mediaTypeNDJSON, peerIDStr, "", "") + require.Equal(t, 200, resp.StatusCode) + + require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedBody := `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"}` + "\n" + `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}` + "\n" + require.Equal(t, expectedBody, string(body)) + }) + + t.Run("GET /routing/v1/dht/closest/peers/{"+tc.peerIdType+"} returns 200 with correct body and headers (JSON response)", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(iter.FromSlice(results), nil) + + resp := makeRequest(t, router, mediaTypeJSON, peerIDStr, "", "") + require.Equal(t, 200, resp.StatusCode) + + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedBody := `{"Peers":[{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"},{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}]}` + require.Equal(t, expectedBody, string(body)) + }) + } +} + func makeName(t *testing.T) (crypto.PrivKey, ipns.Name) { sk, pid := makeEd25519PeerID(t) return sk, ipns.NameFromPeer(pid) @@ -929,3 +1266,12 @@ func (m *mockContentRouter) PutIPNS(ctx context.Context, name ipns.Name, record args := m.Called(ctx, name, record) return args.Error(0) } + +func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, peerID, closerThan, count) + a := args.Get(0) + if a == nil { + return nil, args.Error(1) + } + return a.(iter.ResultIter[*types.PeerRecord]), args.Error(1) +} From 5d4e99eb7c2906b807d074d0b0d9735b288b0601 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Wed, 3 Sep 2025 10:23:26 +0200 Subject: [PATCH 03/17] routing/http/client: add support for GetClosestPeers As the server part, this is heavily inspired in FindPeers. --- routing/http/client/client.go | 89 +++++++++++++ routing/http/client/client_test.go | 137 ++++++++++++++++++++ routing/http/contentrouter/contentrouter.go | 3 + 3 files changed, 229 insertions(+) diff --git a/routing/http/client/client.go b/routing/http/client/client.go index 291793622..b97d42d5d 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -11,6 +11,7 @@ import ( "net/http" gourl "net/url" "slices" + "strconv" "strings" "time" @@ -638,3 +639,91 @@ func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Recor return nil } + +// GetClosestPeers obtains the closest peers to the given peer ID. +func (c *Client) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (peers iter.ResultIter[*types.PeerRecord], err error) { + m := newMeasurement("GetClosestPeers") + + // Build the base URL path + u, err := gourl.JoinPath(c.baseURL, "routing/v1/dht/closest/peers", peer.ToCid(peerID).String()) + if err != nil { + return nil, err + } + + // Add query parameters + queryParams := make(gourl.Values) + if closerThan != "" { + queryParams.Set("closerThan", closerThan.String()) + } + if count > 0 { + queryParams.Set("count", strconv.Itoa(count)) + } + u += "?" + queryParams.Encode() + + // Create the HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", c.accepts) + + m.host = req.Host + start := c.clock.Now() + resp, err := c.httpClient.Do(req) + m.latency = c.clock.Since(start) + m.err = err + + if err != nil { + m.record(ctx) + return nil, err + } + + m.statusCode = resp.StatusCode + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + m.record(ctx) + return iter.FromSlice[iter.Result[*types.PeerRecord]](nil), nil + } + + if resp.StatusCode != http.StatusOK { + err := httpError(resp.StatusCode, resp.Body) + resp.Body.Close() + m.record(ctx) + return nil, err + } + + respContentType := resp.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(respContentType) + if err != nil { + resp.Body.Close() + m.err = err + m.record(ctx) + return nil, fmt.Errorf("parsing Content-Type: %w", err) + } + + m.mediaType = mediaType + + var skipBodyClose bool + defer func() { + if !skipBodyClose { + resp.Body.Close() + } + }() + + var it iter.ResultIter[*types.PeerRecord] + switch mediaType { + case mediaTypeJSON: + parsedResp := &jsontypes.PeersResponse{} + err = json.NewDecoder(resp.Body).Decode(parsedResp) + var sliceIt iter.Iter[*types.PeerRecord] = iter.FromSlice(parsedResp.Peers) + it = iter.ToResultIter(sliceIt) + case mediaTypeNDJSON: + skipBodyClose = true + it = ndjson.NewPeerRecordsIter(resp.Body) + default: + logger.Errorw("unknown media type", "MediaType", mediaType, "ContentType", respContentType) + return nil, errors.New("unknown content type") + } + + return &measuringIter[iter.Result[*types.PeerRecord]]{Iter: it, ctx: ctx, m: m}, nil +} diff --git a/routing/http/client/client_test.go b/routing/http/client/client_test.go index 6171fe85f..7ec3db05b 100644 --- a/routing/http/client/client_test.go +++ b/routing/http/client/client_test.go @@ -48,6 +48,11 @@ func (m *mockContentRouter) FindPeers(ctx context.Context, pid peer.ID, limit in return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } +func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, peerID, closerThan, count) + return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) +} + func (m *mockContentRouter) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) { args := m.Called(ctx, name) rec, _ := args.Get(0).(*ipns.Record) @@ -836,6 +841,138 @@ func TestClient_EmptyResponses(t *testing.T) { } } +func TestClient_GetClosestPeers(t *testing.T) { + bitswapPeerRecord := makePeerRecord([]string{"transport-bitswap"}) + httpPeerRecord := makePeerRecord([]string{"transport-ipfs-gateway-http"}) + + peerRecords := []iter.Result[*types.PeerRecord]{ + {Val: &bitswapPeerRecord}, + {Val: &httpPeerRecord}, + } + + pid := *bitswapPeerRecord.ID + + cases := []struct { + name string + httpStatusCode int + stopServer bool + routerResult []iter.Result[*types.PeerRecord] + routerErr error + clientRequiresStreaming bool + serverStreamingDisabled bool + + expErrContains osErrContains + expResult []iter.Result[*types.PeerRecord] + expStreamingResponse bool + expJSONResponse bool + }{ + { + name: "happy case", + routerResult: peerRecords, + expResult: peerRecords, + expStreamingResponse: true, + }, + { + name: "server doesn't support streaming", + routerResult: peerRecords, + expResult: peerRecords, + serverStreamingDisabled: true, + expJSONResponse: true, + }, + { + name: "client requires streaming but server doesn't support it", + serverStreamingDisabled: true, + clientRequiresStreaming: true, + expErrContains: osErrContains{expContains: "HTTP error with StatusCode=400: no supported content types"}, + }, + { + name: "returns an error if there's a non-200 response", + httpStatusCode: 500, + expErrContains: osErrContains{expContains: "HTTP error with StatusCode=500"}, + }, + { + name: "returns an error if the HTTP client returns a non-HTTP error", + stopServer: true, + expErrContains: osErrContains{ + expContains: "connect: connection refused", + expContainsWin: "connectex: No connection could be made because the target machine actively refused it.", + }, + }, + { + name: "returns no providers if the HTTP server returns a 404 response", + httpStatusCode: 404, + expResult: nil, + }, + { + name: "passes count and closerThan along", + expStreamingResponse: true, + routerResult: peerRecords, + expResult: peerRecords, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var clientOpts []Option + var serverOpts []server.Option + var onRespReceived []func(*http.Response) + var onReqReceived []func(*http.Request) + + if c.serverStreamingDisabled { + serverOpts = append(serverOpts, server.WithStreamingResultsDisabled()) + } + + if c.clientRequiresStreaming { + clientOpts = append(clientOpts, WithStreamResultsRequired()) + onReqReceived = append(onReqReceived, func(r *http.Request) { + assert.Equal(t, mediaTypeNDJSON, r.Header.Get("Accept")) + }) + } + + if c.expStreamingResponse { + onRespReceived = append(onRespReceived, func(r *http.Response) { + assert.Equal(t, mediaTypeNDJSON, r.Header.Get("Content-Type")) + }) + } + + if c.expJSONResponse { + onRespReceived = append(onRespReceived, func(r *http.Response) { + assert.Equal(t, mediaTypeJSON, r.Header.Get("Content-Type")) + }) + } + + deps := makeTestDeps(t, clientOpts, serverOpts) + + deps.recordingHTTPClient.f = append(deps.recordingHTTPClient.f, onRespReceived...) + deps.recordingHandler.f = append(deps.recordingHandler.f, onReqReceived...) + + client := deps.client + router := deps.router + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if c.httpStatusCode != 0 { + deps.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(c.httpStatusCode) + }) + } + + if c.stopServer { + deps.server.Close() + } + + routerResultIter := iter.FromSlice(c.routerResult) + router.On("GetClosestPeers", mock.Anything, pid).Return(routerResultIter, c.routerErr) + + resultIter, err := client.GetClosestPeers(ctx, pid) + c.expErrContains.errContains(t, err) + + results := iter.ReadAll(resultIter) + assert.Equal(t, c.expResult, results) + }) + } +} + func TestNormalizeBaseURL(t *testing.T) { cases := []struct { name string diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 8a8cfb8ae..13264e14d 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -23,6 +23,7 @@ var logger = logging.Logger("routing/http/contentrouter") const ttl = 24 * time.Hour +// A Client provides HTTP Content Routing methods. See also [server.ContentRouter]. type Client interface { FindProviders(ctx context.Context, key cid.Cid) (iter.ResultIter[types.Record], error) ProvideBitswap(ctx context.Context, keys []cid.Cid, ttl time.Duration) (time.Duration, error) @@ -59,6 +60,8 @@ func WithMaxProvideBatchSize(max int) option { } } +// NewContentRoutingClient returns a client that conforms to the +// ContentRouting interfaces. func NewContentRoutingClient(c Client, opts ...option) *contentRouter { cr := &contentRouter{ client: c, From d52bcb1c324f8c9c0bcb548e1dc0aed4398727d4 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Thu, 4 Sep 2025 13:27:59 +0200 Subject: [PATCH 04/17] routing/http/contentrouter: add support for GetClosestPeers --- examples/go.mod | 2 +- examples/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- routing/http/contentrouter/contentrouter.go | 46 ++++++++ .../http/contentrouter/contentrouter_test.go | 107 ++++++++++++++++++ routing/http/server/server.go | 4 +- routing/http/server/server_test.go | 2 +- 8 files changed, 161 insertions(+), 10 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 03dd51d7d..7961bf2f9 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -76,7 +76,7 @@ require ( github.com/libp2p/go-libp2p-kad-dht v0.34.0 // indirect github.com/libp2p/go-libp2p-kbucket v0.7.0 // indirect github.com/libp2p/go-libp2p-record v0.3.1 // indirect - github.com/libp2p/go-libp2p-routing-helpers v0.7.5 // indirect + github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c // indirect github.com/libp2p/go-msgio v0.3.0 // indirect github.com/libp2p/go-netroute v0.2.2 // indirect github.com/libp2p/go-reuseport v0.4.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index 67e998bb5..1074b2906 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -231,8 +231,8 @@ github.com/libp2p/go-libp2p-kbucket v0.7.0 h1:vYDvRjkyJPeWunQXqcW2Z6E93Ywx7fX0jg github.com/libp2p/go-libp2p-kbucket v0.7.0/go.mod h1:blOINGIj1yiPYlVEX0Rj9QwEkmVnz3EP8LK1dRKBC6g= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= -github.com/libp2p/go-libp2p-routing-helpers v0.7.5 h1:HdwZj9NKovMx0vqq6YNPTh6aaNzey5zHD7HeLJtq6fI= -github.com/libp2p/go-libp2p-routing-helpers v0.7.5/go.mod h1:3YaxrwP0OBPDD7my3D0KxfR89FlcX/IEbxDEDfAmj98= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c h1:oWvPNbSi3yoJMDe04qvICNpwrKoub+x0EDb3bjc5cxs= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= diff --git a/go.mod b/go.mod index 91eab8056..126df3d78 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/libp2p/go-libp2p v0.43.0 github.com/libp2p/go-libp2p-kad-dht v0.34.0 github.com/libp2p/go-libp2p-record v0.3.1 - github.com/libp2p/go-libp2p-routing-helpers v0.7.5 + github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c github.com/libp2p/go-libp2p-testing v0.12.0 github.com/libp2p/go-msgio v0.3.0 github.com/miekg/dns v1.1.68 diff --git a/go.sum b/go.sum index 5de1ae885..ebd43d77f 100644 --- a/go.sum +++ b/go.sum @@ -230,8 +230,8 @@ github.com/libp2p/go-libp2p-kbucket v0.7.0 h1:vYDvRjkyJPeWunQXqcW2Z6E93Ywx7fX0jg github.com/libp2p/go-libp2p-kbucket v0.7.0/go.mod h1:blOINGIj1yiPYlVEX0Rj9QwEkmVnz3EP8LK1dRKBC6g= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= -github.com/libp2p/go-libp2p-routing-helpers v0.7.5 h1:HdwZj9NKovMx0vqq6YNPTh6aaNzey5zHD7HeLJtq6fI= -github.com/libp2p/go-libp2p-routing-helpers v0.7.5/go.mod h1:3YaxrwP0OBPDD7my3D0KxfR89FlcX/IEbxDEDfAmj98= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c h1:oWvPNbSi3yoJMDe04qvICNpwrKoub+x0EDb3bjc5cxs= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 13264e14d..af67e2b7a 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -30,6 +30,9 @@ type Client interface { FindPeers(ctx context.Context, pid peer.ID) (peers iter.ResultIter[*types.PeerRecord], err error) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error + // GetClosestPeers returns the DHT closest peers to the given peer ID. + // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). + GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) } type contentRouter struct { @@ -44,6 +47,7 @@ var ( _ routing.ValueStore = (*contentRouter)(nil) _ routinghelpers.ProvideManyRouter = (*contentRouter)(nil) _ routinghelpers.ReadyAbleRouter = (*contentRouter)(nil) + _ routinghelpers.DHTRouter = (*contentRouter)(nil) ) type option func(c *contentRouter) @@ -303,3 +307,45 @@ func (c *contentRouter) SearchValue(ctx context.Context, key string, opts ...rou return ch, nil } + +func (c *contentRouter) GetClosestPeers(ctx context.Context, pid, closerThan peer.ID, count int) (<-chan peer.AddrInfo, error) { + iter, err := c.client.GetClosestPeers(ctx, pid, closerThan, count) + if err != nil { + return nil, err + } + defer iter.Close() + + infos := make(chan peer.AddrInfo) + go func() { + defer close(infos) + for iter.Next() { + res := iter.Val() + if res.Err != nil { + logger.Warnf("error iterating peer responses: %s", res.Err) + continue + } + + var addrs []multiaddr.Multiaddr + for _, a := range res.Val.Addrs { + addrs = append(addrs, a.Multiaddr) + } + + // If there are no addresses there's nothing of value to return + if len(addrs) == 0 { + continue + } + + select { + case <-ctx.Done(): + logger.Warnf("aborting GetClosestPeers: %s", ctx.Err()) + return + case infos <- peer.AddrInfo{ + ID: *res.Val.ID, + Addrs: addrs, + }: + } + } + }() + + return infos, nil +} diff --git a/routing/http/contentrouter/contentrouter_test.go b/routing/http/contentrouter/contentrouter_test.go index 839293617..d92a5fd47 100644 --- a/routing/http/contentrouter/contentrouter_test.go +++ b/routing/http/contentrouter/contentrouter_test.go @@ -53,6 +53,11 @@ func (m *mockClient) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.R return args.Error(0) } +func (m *mockClient) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, peerID, closerThan, count) + return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) +} + func TestProvide(t *testing.T) { for _, c := range []struct { name string @@ -258,6 +263,108 @@ func TestFindPeerNoPeer(t *testing.T) { require.ErrorIs(t, err, routing.ErrNotFound) } +func TestGetClosestPeers(t *testing.T) { + t.Run("returns a channel and can read all results", func(t *testing.T) { + ctx := context.Background() + client := &mockClient{} + crc := NewContentRoutingClient(client) + + peerID := peer.ID("test-peer") + closerThan := peer.ID("test-peer-2") + count := 2 + + // Mock response with two peer records + peer1 := peer.ID("peer1") + peer2 := peer.ID("peer2") + addr1 := multiaddr.StringCast("/ip4/1.2.3.4/tcp/1234") + addr2 := multiaddr.StringCast("/ip4/5.6.7.8/tcp/5678") + addrs1 := []types.Multiaddr{{Multiaddr: addr1}} + addrs2 := []types.Multiaddr{{Multiaddr: addr2}} + peerRec1 := &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &peer1, + Addrs: addrs1, + Protocols: []string{"transport-bitswap"}, + } + peerRec2 := &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &peer2, + Addrs: addrs2, + Protocols: []string{"transport-bitswap"}, + } + + peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{peerRec1, peerRec2})) + + client.On("GetClosestPeers", ctx, peerID, closerThan, count).Return(peerIter, nil) + + infos, err := crc.GetClosestPeers(ctx, peerID, closerThan, count) + require.NoError(t, err) + + var actual []peer.AddrInfo + for info := range infos { + actual = append(actual, info) + } + + expected := []peer.AddrInfo{ + {ID: peer1, Addrs: []multiaddr.Multiaddr{addr1}}, + {ID: peer2, Addrs: []multiaddr.Multiaddr{addr2}}, + } + + assert.Equal(t, expected, actual) + }) + + t.Run("returns no results if addrs is empty", func(t *testing.T) { + ctx := context.Background() + client := &mockClient{} + crc := NewContentRoutingClient(client) + + peerID := peer.ID("test-peer") + closerThan := peer.ID("closer-than") + count := 1 + + peer1 := peer.ID("peer1") + peerRec1 := &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &peer1, + Protocols: []string{"transport-bitswap"}, + // no addresses + } + + // Mock response with an empty iterator + peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{peerRec1})) + + client.On("GetClosestPeers", ctx, peerID, closerThan, count).Return(peerIter, nil) + + infos, err := crc.GetClosestPeers(ctx, peerID, closerThan, count) + require.NoError(t, err) + + var actual []peer.AddrInfo + for info := range infos { + actual = append(actual, info) + } + + assert.Empty(t, actual) + }) + + t.Run("returns an error if call errors", func(t *testing.T) { + ctx := context.Background() + client := &mockClient{} + crc := NewContentRoutingClient(client) + + peerID := peer.ID("test-peer") + closerThan := peer.ID("closer-than") + count := 1 + + // Mock error response + peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{})) + client.On("GetClosestPeers", ctx, peerID, closerThan, count).Return(peerIter, assert.AnError) + + infos, err := crc.GetClosestPeers(ctx, peerID, closerThan, count) + require.ErrorIs(t, err, assert.AnError) + assert.Nil(t, infos) + }) +} + func makeName(t *testing.T) (crypto.PrivKey, ipns.Name) { sk, _, err := crypto.GenerateEd25519Key(rand.Reader) require.NoError(t, err) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index b7f14247f..baf84eab5 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -631,9 +631,7 @@ func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { return } - var ( - handlerFunc func(w http.ResponseWriter, provIter iter.ResultIter[*types.PeerRecord]) - ) + var handlerFunc func(w http.ResponseWriter, provIter iter.ResultIter[*types.PeerRecord]) if mediaType == mediaTypeNDJSON { handlerFunc = s.getClosestPeersNDJSON diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index a3e25f9f9..1eb3ecf00 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -713,7 +713,7 @@ func TestGetClosestPeers(t *testing.T) { requireCloseToNow(t, resp.Header.Get("Last-Modified")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?count=? is bewteen[0-100] per spec", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?count=? is between[0-100] per spec", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) From 02649293ffbdf12c87e0ed13a4fe639611de7176 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 13 Sep 2025 02:32:56 +0200 Subject: [PATCH 05/17] fix: use 'closer-than' query parameter for spec compliance Changes query parameter from 'closerThan' to 'closer-than' in routing/http client and server to match IPIP-0476 specification. --- routing/http/client/client.go | 2 +- routing/http/server/server.go | 2 +- routing/http/server/server_test.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/routing/http/client/client.go b/routing/http/client/client.go index b97d42d5d..8fdbbb790 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -653,7 +653,7 @@ func (c *Client) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID // Add query parameters queryParams := make(gourl.Values) if closerThan != "" { - queryParams.Set("closerThan", closerThan.String()) + queryParams.Set("closer-than", closerThan.String()) } if count > 0 { queryParams.Set("count", strconv.Itoa(count)) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index baf84eab5..a52982026 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -602,7 +602,7 @@ func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { } query := r.URL.Query() - closerThanStr := query.Get("closerThan") + closerThanStr := query.Get("closer-than") var closerThanPid peer.ID if closerThanStr != "" { // it is fine to omit. We will pass an empty peer.ID then. closerThanPid, err = parsePeerID(closerThanStr) diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index 1eb3ecf00..f7ac0627b 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -673,7 +673,7 @@ func TestGetClosestPeers(t *testing.T) { server := httptest.NewServer(Handler(router)) t.Cleanup(server.Close) - urlStr := fmt.Sprintf("http://%s/routing/v1/dht/closest/peers/%s?closerThan=%s&count=%s", server.Listener.Addr().String(), arg, closerThan, count) + urlStr := fmt.Sprintf("http://%s/routing/v1/dht/closest/peers/%s?closer-than=%s&count=%s", server.Listener.Addr().String(), arg, closerThan, count) t.Log(urlStr) req, err := http.NewRequest(http.MethodGet, urlStr, nil) @@ -746,7 +746,7 @@ func TestGetClosestPeers(t *testing.T) { } }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?closerThan=? gets parsed or set to empty peer.ID", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?closer-than=? gets parsed or set to empty peer.ID", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) From 39432b9033feb0ff1bc0a32dde2c15708cf65d66 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 13 Sep 2025 02:39:26 +0200 Subject: [PATCH 06/17] fix: use correct variable in error message for closer-than parameter --- routing/http/server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index a52982026..e6216c280 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -607,7 +607,7 @@ func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { if closerThanStr != "" { // it is fine to omit. We will pass an empty peer.ID then. closerThanPid, err = parsePeerID(closerThanStr) if err != nil { - writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse closer-than PeerID %q: %w", pidStr, err)) + writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse closer-than PeerID %q: %w", closerThanStr, err)) return } } From 384effb8af535171c5f278a82bd19ce62a835fc5 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 13 Sep 2025 03:04:43 +0200 Subject: [PATCH 07/17] docs: improve GetClosestPeers godoc and use amino constant - clarified parameter behavior in godoc - documented amino dht limitation of 20 peers max - use amino.DefaultBucketSize constant for default count --- routing/http/server/server.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index e6216c280..d74e5569d 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -24,6 +24,7 @@ import ( jsontypes "github.com/ipfs/boxo/routing/http/types/json" "github.com/ipfs/go-cid" logging "github.com/ipfs/go-log/v2" + "github.com/libp2p/go-libp2p-kad-dht/amino" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/routing" "github.com/multiformats/go-multiaddr" @@ -43,7 +44,7 @@ const ( DefaultRecordsLimit = 20 DefaultStreamingRecordsLimit = 0 DefaultRoutingTimeout = 30 * time.Second - DefaultGetClosestPeersCount = 20 + DefaultGetClosestPeersCount = amino.DefaultBucketSize // 20 - Amino DHT bucket size ) var logger = logging.Logger("routing/http/server") @@ -82,8 +83,14 @@ type ContentRouter interface { // It is guaranteed that the record matches the provided name. PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error - // GetClosestPeers returns the DHT closest peers to the given peer ID. - // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). + // GetClosestPeers returns up to count peers closest to the given peerID in the DHT keyspace. + // + // If peerID is empty, implementations should use their own peer ID. + // If closerThan is specified, only peers closer to peerID than closerThan are returned. + // If count is 0, a sensible default of the routing system is used. + // + // Note: Amino DHT implementations are limited to returning at most 20 peers + // due to the DHT bucket size. GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) } From d53886a4c7c0457e8418e344865b4ad7eecb6b62 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 13 Sep 2025 03:40:32 +0200 Subject: [PATCH 08/17] refactor: rename ContentRouter to DelegatedRouter - introduce DelegatedRouter interface for delegated routing operations - keep ContentRouter as deprecated alias for backward compatibility - update internal server struct to use DelegatedRouter - clarify interface focuses on querying with IPNS publishing support - note that additional publishing methods may be added via IPIP process this better aligns with the Delegated Routing V1 HTTP API spec and clarifies that this interface handles all routing operations (content, peers, values, DHT), not just content routing --- routing/http/contentrouter/contentrouter.go | 2 +- routing/http/server/server.go | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index af67e2b7a..380d24d92 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -23,7 +23,7 @@ var logger = logging.Logger("routing/http/contentrouter") const ttl = 24 * time.Hour -// A Client provides HTTP Content Routing methods. See also [server.ContentRouter]. +// A Client provides HTTP Delegated Routing methods. See also [server.DelegatedRouter]. type Client interface { FindProviders(ctx context.Context, key cid.Cid) (iter.ResultIter[types.Record], error) ProvideBitswap(ctx context.Context, keys []cid.Cid, ttl time.Duration) (time.Duration, error) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index d74e5569d..8977a4bfa 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -62,7 +62,14 @@ type FindProvidersAsyncResponse struct { Error error } -type ContentRouter interface { +// DelegatedRouter provides the Delegated Routing V1 HTTP API for offloading +// routing operations to another process/server. +// +// This interface focuses on querying operations for content providers, peers, +// IPNS records, and DHT routing information. It also supports delegated IPNS +// publishing. Additional publishing methods may be added in the future via the +// IPIP process as the ecosystem evolves and new needs arise. +type DelegatedRouter interface { // FindProviders searches for peers who are able to provide the given [cid.Cid]. // Limit indicates the maximum amount of results to return; 0 means unbounded. FindProviders(ctx context.Context, cid cid.Cid, limit int) (iter.ResultIter[types.Record], error) @@ -94,6 +101,10 @@ type ContentRouter interface { GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) } +// ContentRouter is deprecated, use DelegatedRouter instead. +// Deprecated: use DelegatedRouter. ContentRouter will be removed in a future version. +type ContentRouter = DelegatedRouter + // Deprecated: protocol-agnostic provide is being worked on in [IPIP-378]: // // [IPIP-378]: https://github.com/ipfs/specs/pull/378 @@ -205,7 +216,7 @@ func Handler(svc ContentRouter, opts ...Option) http.Handler { var handlerCount atomic.Int32 type server struct { - svc ContentRouter + svc DelegatedRouter disableNDJSON bool recordsLimit int streamingRecordsLimit int From 0a8bc14175c8d1f36af350aa382948c72297d7df Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Thu, 25 Sep 2025 14:18:29 +0200 Subject: [PATCH 09/17] GetClosestPeers(): remove closer-than and timeout parameters. This aligns it with latest version of the spec. --- go.mod | 2 +- go.sum | 4 +- routing/http/client/client.go | 13 +- routing/http/client/client_test.go | 10 +- routing/http/contentrouter/contentrouter.go | 8 +- .../http/contentrouter/contentrouter_test.go | 22 ++- routing/http/server/server.go | 41 +----- routing/http/server/server_test.go | 129 +++++------------- 8 files changed, 55 insertions(+), 174 deletions(-) diff --git a/go.mod b/go.mod index 126df3d78..ce8be6d4f 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/libp2p/go-libp2p v0.43.0 github.com/libp2p/go-libp2p-kad-dht v0.34.0 github.com/libp2p/go-libp2p-record v0.3.1 - github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c + github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449 github.com/libp2p/go-libp2p-testing v0.12.0 github.com/libp2p/go-msgio v0.3.0 github.com/miekg/dns v1.1.68 diff --git a/go.sum b/go.sum index ebd43d77f..1a07e8a1d 100644 --- a/go.sum +++ b/go.sum @@ -230,8 +230,8 @@ github.com/libp2p/go-libp2p-kbucket v0.7.0 h1:vYDvRjkyJPeWunQXqcW2Z6E93Ywx7fX0jg github.com/libp2p/go-libp2p-kbucket v0.7.0/go.mod h1:blOINGIj1yiPYlVEX0Rj9QwEkmVnz3EP8LK1dRKBC6g= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c h1:oWvPNbSi3yoJMDe04qvICNpwrKoub+x0EDb3bjc5cxs= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449 h1:Rfa4ltusUBgkPpRBXQdGBLMAzzoBMb+76sVGOblunTg= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= diff --git a/routing/http/client/client.go b/routing/http/client/client.go index 8fdbbb790..4e99a7203 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -11,7 +11,6 @@ import ( "net/http" gourl "net/url" "slices" - "strconv" "strings" "time" @@ -641,7 +640,7 @@ func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Recor } // GetClosestPeers obtains the closest peers to the given peer ID. -func (c *Client) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (peers iter.ResultIter[*types.PeerRecord], err error) { +func (c *Client) GetClosestPeers(ctx context.Context, peerID peer.ID) (peers iter.ResultIter[*types.PeerRecord], err error) { m := newMeasurement("GetClosestPeers") // Build the base URL path @@ -650,16 +649,6 @@ func (c *Client) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID return nil, err } - // Add query parameters - queryParams := make(gourl.Values) - if closerThan != "" { - queryParams.Set("closer-than", closerThan.String()) - } - if count > 0 { - queryParams.Set("count", strconv.Itoa(count)) - } - u += "?" + queryParams.Encode() - // Create the HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { diff --git a/routing/http/client/client_test.go b/routing/http/client/client_test.go index 7ec3db05b..fe992eeea 100644 --- a/routing/http/client/client_test.go +++ b/routing/http/client/client_test.go @@ -48,8 +48,8 @@ func (m *mockContentRouter) FindPeers(ctx context.Context, pid peer.ID, limit in return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } -func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) { - args := m.Called(ctx, peerID, closerThan, count) +func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, peerID) return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } @@ -903,12 +903,6 @@ func TestClient_GetClosestPeers(t *testing.T) { httpStatusCode: 404, expResult: nil, }, - { - name: "passes count and closerThan along", - expStreamingResponse: true, - routerResult: peerRecords, - expResult: peerRecords, - }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 380d24d92..982272d1f 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -31,8 +31,8 @@ type Client interface { GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error // GetClosestPeers returns the DHT closest peers to the given peer ID. - // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). - GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) + // If empty, it will use the content router's peer ID (self). + GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) } type contentRouter struct { @@ -308,8 +308,8 @@ func (c *contentRouter) SearchValue(ctx context.Context, key string, opts ...rou return ch, nil } -func (c *contentRouter) GetClosestPeers(ctx context.Context, pid, closerThan peer.ID, count int) (<-chan peer.AddrInfo, error) { - iter, err := c.client.GetClosestPeers(ctx, pid, closerThan, count) +func (c *contentRouter) GetClosestPeers(ctx context.Context, pid peer.ID) (<-chan peer.AddrInfo, error) { + iter, err := c.client.GetClosestPeers(ctx, pid) if err != nil { return nil, err } diff --git a/routing/http/contentrouter/contentrouter_test.go b/routing/http/contentrouter/contentrouter_test.go index d92a5fd47..98fb2228e 100644 --- a/routing/http/contentrouter/contentrouter_test.go +++ b/routing/http/contentrouter/contentrouter_test.go @@ -53,8 +53,8 @@ func (m *mockClient) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.R return args.Error(0) } -func (m *mockClient) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) { - args := m.Called(ctx, peerID, closerThan, count) +func (m *mockClient) GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, peerID) return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } @@ -270,8 +270,6 @@ func TestGetClosestPeers(t *testing.T) { crc := NewContentRoutingClient(client) peerID := peer.ID("test-peer") - closerThan := peer.ID("test-peer-2") - count := 2 // Mock response with two peer records peer1 := peer.ID("peer1") @@ -295,9 +293,9 @@ func TestGetClosestPeers(t *testing.T) { peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{peerRec1, peerRec2})) - client.On("GetClosestPeers", ctx, peerID, closerThan, count).Return(peerIter, nil) + client.On("GetClosestPeers", ctx, peerID).Return(peerIter, nil) - infos, err := crc.GetClosestPeers(ctx, peerID, closerThan, count) + infos, err := crc.GetClosestPeers(ctx, peerID) require.NoError(t, err) var actual []peer.AddrInfo @@ -319,8 +317,6 @@ func TestGetClosestPeers(t *testing.T) { crc := NewContentRoutingClient(client) peerID := peer.ID("test-peer") - closerThan := peer.ID("closer-than") - count := 1 peer1 := peer.ID("peer1") peerRec1 := &types.PeerRecord{ @@ -333,9 +329,9 @@ func TestGetClosestPeers(t *testing.T) { // Mock response with an empty iterator peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{peerRec1})) - client.On("GetClosestPeers", ctx, peerID, closerThan, count).Return(peerIter, nil) + client.On("GetClosestPeers", ctx, peerID).Return(peerIter, nil) - infos, err := crc.GetClosestPeers(ctx, peerID, closerThan, count) + infos, err := crc.GetClosestPeers(ctx, peerID) require.NoError(t, err) var actual []peer.AddrInfo @@ -352,14 +348,12 @@ func TestGetClosestPeers(t *testing.T) { crc := NewContentRoutingClient(client) peerID := peer.ID("test-peer") - closerThan := peer.ID("closer-than") - count := 1 // Mock error response peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{})) - client.On("GetClosestPeers", ctx, peerID, closerThan, count).Return(peerIter, assert.AnError) + client.On("GetClosestPeers", ctx, peerID).Return(peerIter, assert.AnError) - infos, err := crc.GetClosestPeers(ctx, peerID, closerThan, count) + infos, err := crc.GetClosestPeers(ctx, peerID) require.ErrorIs(t, err, assert.AnError) assert.Nil(t, infos) }) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 8977a4bfa..66ec66419 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -9,7 +9,6 @@ import ( "io" "mime" "net/http" - "strconv" "strings" "sync/atomic" "time" @@ -24,7 +23,6 @@ import ( jsontypes "github.com/ipfs/boxo/routing/http/types/json" "github.com/ipfs/go-cid" logging "github.com/ipfs/go-log/v2" - "github.com/libp2p/go-libp2p-kad-dht/amino" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/routing" "github.com/multiformats/go-multiaddr" @@ -44,7 +42,6 @@ const ( DefaultRecordsLimit = 20 DefaultStreamingRecordsLimit = 0 DefaultRoutingTimeout = 30 * time.Second - DefaultGetClosestPeersCount = amino.DefaultBucketSize // 20 - Amino DHT bucket size ) var logger = logging.Logger("routing/http/server") @@ -90,15 +87,9 @@ type DelegatedRouter interface { // It is guaranteed that the record matches the provided name. PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error - // GetClosestPeers returns up to count peers closest to the given peerID in the DHT keyspace. - // - // If peerID is empty, implementations should use their own peer ID. - // If closerThan is specified, only peers closer to peerID than closerThan are returned. - // If count is 0, a sensible default of the routing system is used. - // - // Note: Amino DHT implementations are limited to returning at most 20 peers - // due to the DHT bucket size. - GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) + // GetClosestPeers returns the DHT closest peers to the given peer ID. + // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). + GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) } // ContentRouter is deprecated, use DelegatedRouter instead. @@ -619,30 +610,6 @@ func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { return } - query := r.URL.Query() - closerThanStr := query.Get("closer-than") - var closerThanPid peer.ID - if closerThanStr != "" { // it is fine to omit. We will pass an empty peer.ID then. - closerThanPid, err = parsePeerID(closerThanStr) - if err != nil { - writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse closer-than PeerID %q: %w", closerThanStr, err)) - return - } - } - - countStr := query.Get("count") - count, err := strconv.Atoi(countStr) - if err != nil { - count = 0 - } - if count > 100 { - count = 100 - } - // If limit is still 0, set THE default. - if count <= 0 { - count = DefaultGetClosestPeersCount - } - mediaType, err := s.detectResponseType(r) if err != nil { writeErr(w, "GetClosestPeers", http.StatusBadRequest, err) @@ -661,7 +628,7 @@ func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), s.routingTimeout) defer cancel() - provIter, err := s.svc.GetClosestPeers(ctx, pid, closerThanPid, count) + provIter, err := s.svc.GetClosestPeers(ctx, pid) if err != nil { if errors.Is(err, routing.ErrNotFound) { // handlerFunc takes care of setting the 404 and necessary headers diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index f7ac0627b..a85546219 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -669,11 +669,11 @@ func TestPeers(t *testing.T) { } func TestGetClosestPeers(t *testing.T) { - makeRequest := func(t *testing.T, router *mockContentRouter, contentType, arg, closerThan, count string) *http.Response { + makeRequest := func(t *testing.T, router *mockContentRouter, contentType, arg string) *http.Response { server := httptest.NewServer(Handler(router)) t.Cleanup(server.Close) - urlStr := fmt.Sprintf("http://%s/routing/v1/dht/closest/peers/%s?closer-than=%s&count=%s", server.Listener.Addr().String(), arg, closerThan, count) + urlStr := fmt.Sprintf("http://%s/routing/v1/dht/closest/peers/%s", server.Listener.Addr().String(), arg) t.Log(urlStr) req, err := http.NewRequest(http.MethodGet, urlStr, nil) @@ -690,21 +690,21 @@ func TestGetClosestPeers(t *testing.T) { t.Parallel() router := &mockContentRouter{} - resp := makeRequest(t, router, mediaTypeJSON, "nonpeerid", "", "") + resp := makeRequest(t, router, mediaTypeJSON, "nonpeerid") require.Equal(t, 400, resp.StatusCode) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, explicit JSON)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, explicit JSON)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) - resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), "", "") - require.Equal(t, 404, resp.StatusCode) + resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String()) + require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) require.Equal(t, "Accept", resp.Header.Get("Vary")) @@ -713,116 +713,53 @@ func TestGetClosestPeers(t *testing.T) { requireCloseToNow(t, resp.Header.Get("Last-Modified")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?count=? is between[0-100] per spec", func(t *testing.T) { - t.Parallel() - - _, pid := makeEd25519PeerID(t) - - testCases := []struct { - value string - expected int - }{ - {"", 20}, - {"0", 20}, - {"55", 55}, - {"110", 100}, - {"-5", 20}, - {"abc", 20}, - } - - for _, tc := range testCases { - results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ - {Val: &types.PeerRecord{ - Schema: types.SchemaPeer, - ID: &pid, - Protocols: []string{"transport-bitswap", "transport-foo"}, - Addrs: []types.Multiaddr{}, - }}, - }) - router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), tc.expected).Return(results, nil) - resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), "", tc.value) - require.Equal(t, 200, resp.StatusCode) - } - }) - - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id}?closer-than=? gets parsed or set to empty peer.ID", func(t *testing.T) { - t.Parallel() - - _, pid := makeEd25519PeerID(t) - _, closerThanPid := makeEd25519PeerID(t) - - testCases := []struct { - value string - expected peer.ID - }{ - {"", peer.ID("")}, - {peer.ToCid(closerThanPid).String(), closerThanPid}, - } - - for _, tc := range testCases { - results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ - {Val: &types.PeerRecord{ - Schema: types.SchemaPeer, - ID: &pid, - Protocols: []string{"transport-bitswap", "transport-foo"}, - Addrs: []types.Multiaddr{}, - }}, - }) - router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, tc.expected, 20).Return(results, nil) - resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), tc.value, "") - require.Equal(t, 200, resp.StatusCode) - } - }) - - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) // Simulate request with Accept header that includes wildcard match - resp := makeRequest(t, router, "text/html,*/*", peer.ToCid(pid).String(), "", "") + resp := makeRequest(t, router, "text/html,*/*", peer.ToCid(pid).String()) // Expect response to default to application/json - require.Equal(t, 404, resp.StatusCode) + require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, implicit JSON, no Accept header)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, implicit JSON, no Accept header)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) // Simulate request without Accept header - resp := makeRequest(t, router, "", peer.ToCid(pid).String(), "", "") + resp := makeRequest(t, router, "", peer.ToCid(pid).String()) // Expect response to default to application/json - require.Equal(t, 404, resp.StatusCode) + require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 when router returns routing.ErrNotFound", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 when router returns routing.ErrNotFound", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(nil, routing.ErrNotFound) + router.On("GetClosestPeers", mock.Anything, pid).Return(nil, routing.ErrNotFound) // Simulate request without Accept header - resp := makeRequest(t, router, "", peer.ToCid(pid).String(), "", "") + resp := makeRequest(t, router, "", peer.ToCid(pid).String()) // Expect response to default to application/json - require.Equal(t, 404, resp.StatusCode) + require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) }) @@ -846,10 +783,10 @@ func TestGetClosestPeers(t *testing.T) { }) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) libp2pKeyCID := peer.ToCid(pid).String() - resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID, "", "") + resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) @@ -865,17 +802,17 @@ func TestGetClosestPeers(t *testing.T) { require.Equal(t, expectedBody, string(body)) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, NDJSON)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, NDJSON)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) - resp := makeRequest(t, router, mediaTypeNDJSON, peer.ToCid(pid).String(), "", "") - require.Equal(t, 404, resp.StatusCode) + resp := makeRequest(t, router, mediaTypeNDJSON, peer.ToCid(pid).String()) + require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) require.Equal(t, "Accept", resp.Header.Get("Vary")) @@ -904,10 +841,10 @@ func TestGetClosestPeers(t *testing.T) { }) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) libp2pKeyCID := peer.ToCid(pid).String() - resp := makeRequest(t, router, mediaTypeNDJSON, libp2pKeyCID, "", "") + resp := makeRequest(t, router, mediaTypeNDJSON, libp2pKeyCID) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) @@ -967,9 +904,9 @@ func TestGetClosestPeers(t *testing.T) { t.Parallel() router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(iter.FromSlice(results), nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(iter.FromSlice(results), nil) - resp := makeRequest(t, router, mediaTypeNDJSON, peerIDStr, "", "") + resp := makeRequest(t, router, mediaTypeNDJSON, peerIDStr) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) @@ -987,9 +924,9 @@ func TestGetClosestPeers(t *testing.T) { t.Parallel() router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid, peer.ID(""), 20).Return(iter.FromSlice(results), nil) + router.On("GetClosestPeers", mock.Anything, pid).Return(iter.FromSlice(results), nil) - resp := makeRequest(t, router, mediaTypeJSON, peerIDStr, "", "") + resp := makeRequest(t, router, mediaTypeJSON, peerIDStr) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) @@ -1267,8 +1204,8 @@ func (m *mockContentRouter) PutIPNS(ctx context.Context, name ipns.Name, record return args.Error(0) } -func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID, closerThan peer.ID, count int) (iter.ResultIter[*types.PeerRecord], error) { - args := m.Called(ctx, peerID, closerThan, count) +func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, peerID) a := args.Get(0) if a == nil { return nil, args.Error(1) From ce702e022d18c989290de8bab49f3ed6feafd175 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Fri, 26 Sep 2025 14:49:26 +0200 Subject: [PATCH 10/17] examples: go mod tidy --- examples/go.mod | 4 ++-- examples/go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 7961bf2f9..15e414677 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,7 +3,7 @@ module github.com/ipfs/boxo/examples go 1.24.0 require ( - github.com/ipfs/boxo v0.34.0 + github.com/ipfs/boxo v0.34.1-0.20250926121443-0a8bc14175c8 github.com/ipfs/go-block-format v0.2.3 github.com/ipfs/go-cid v0.5.0 github.com/ipfs/go-datastore v0.9.0 @@ -76,7 +76,7 @@ require ( github.com/libp2p/go-libp2p-kad-dht v0.34.0 // indirect github.com/libp2p/go-libp2p-kbucket v0.7.0 // indirect github.com/libp2p/go-libp2p-record v0.3.1 // indirect - github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c // indirect + github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449 // indirect github.com/libp2p/go-msgio v0.3.0 // indirect github.com/libp2p/go-netroute v0.2.2 // indirect github.com/libp2p/go-reuseport v0.4.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index 1074b2906..4798efd1e 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -231,8 +231,8 @@ github.com/libp2p/go-libp2p-kbucket v0.7.0 h1:vYDvRjkyJPeWunQXqcW2Z6E93Ywx7fX0jg github.com/libp2p/go-libp2p-kbucket v0.7.0/go.mod h1:blOINGIj1yiPYlVEX0Rj9QwEkmVnz3EP8LK1dRKBC6g= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c h1:oWvPNbSi3yoJMDe04qvICNpwrKoub+x0EDb3bjc5cxs= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250903125449-17ee6fbf872c/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449 h1:Rfa4ltusUBgkPpRBXQdGBLMAzzoBMb+76sVGOblunTg= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= From bdb5505404584af77f4b2df37e6f860ebaf2a4f3 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Mon, 29 Sep 2025 09:27:15 +0200 Subject: [PATCH 11/17] Address review --- routing/http/client/client.go | 17 +++++++---------- routing/http/contentrouter/contentrouter.go | 3 +-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/routing/http/client/client.go b/routing/http/client/client.go index 4e99a7203..7c1bb2ac7 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -667,16 +667,21 @@ func (c *Client) GetClosestPeers(ctx context.Context, peerID peer.ID) (peers ite return nil, err } + var skipBodyClose bool + defer func() { + if !skipBodyClose { + resp.Body.Close() + } + }() + m.statusCode = resp.StatusCode if resp.StatusCode == http.StatusNotFound { - resp.Body.Close() m.record(ctx) return iter.FromSlice[iter.Result[*types.PeerRecord]](nil), nil } if resp.StatusCode != http.StatusOK { err := httpError(resp.StatusCode, resp.Body) - resp.Body.Close() m.record(ctx) return nil, err } @@ -684,7 +689,6 @@ func (c *Client) GetClosestPeers(ctx context.Context, peerID peer.ID) (peers ite respContentType := resp.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(respContentType) if err != nil { - resp.Body.Close() m.err = err m.record(ctx) return nil, fmt.Errorf("parsing Content-Type: %w", err) @@ -692,13 +696,6 @@ func (c *Client) GetClosestPeers(ctx context.Context, peerID peer.ID) (peers ite m.mediaType = mediaType - var skipBodyClose bool - defer func() { - if !skipBodyClose { - resp.Body.Close() - } - }() - var it iter.ResultIter[*types.PeerRecord] switch mediaType { case mediaTypeJSON: diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 982272d1f..06e9656b2 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -313,10 +313,9 @@ func (c *contentRouter) GetClosestPeers(ctx context.Context, pid peer.ID) (<-cha if err != nil { return nil, err } - defer iter.Close() - infos := make(chan peer.AddrInfo) go func() { + defer iter.Close() defer close(infos) for iter.Next() { res := iter.Val() From 07e74fb8113bb02367d9bdc21a55888b414c8996 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Thu, 16 Oct 2025 10:56:03 +0200 Subject: [PATCH 12/17] Routing: GetClosestPeers: replace peer.ID argument with CID --- go.mod | 2 +- go.sum | 4 +- routing/http/client/client.go | 4 +- routing/http/client/client_test.go | 10 +- routing/http/contentrouter/contentrouter.go | 10 +- .../http/contentrouter/contentrouter_test.go | 22 +-- routing/http/server/server.go | 12 +- routing/http/server/server_test.go | 141 ++++-------------- 8 files changed, 64 insertions(+), 141 deletions(-) diff --git a/go.mod b/go.mod index ce8be6d4f..58b7b5c22 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/libp2p/go-libp2p v0.43.0 github.com/libp2p/go-libp2p-kad-dht v0.34.0 github.com/libp2p/go-libp2p-record v0.3.1 - github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449 + github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e github.com/libp2p/go-libp2p-testing v0.12.0 github.com/libp2p/go-msgio v0.3.0 github.com/miekg/dns v1.1.68 diff --git a/go.sum b/go.sum index 1a07e8a1d..88e851539 100644 --- a/go.sum +++ b/go.sum @@ -230,8 +230,8 @@ github.com/libp2p/go-libp2p-kbucket v0.7.0 h1:vYDvRjkyJPeWunQXqcW2Z6E93Ywx7fX0jg github.com/libp2p/go-libp2p-kbucket v0.7.0/go.mod h1:blOINGIj1yiPYlVEX0Rj9QwEkmVnz3EP8LK1dRKBC6g= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449 h1:Rfa4ltusUBgkPpRBXQdGBLMAzzoBMb+76sVGOblunTg= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20250925120012-fe2c8e446449/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e h1:6DSfN9gsAmBa1iyAKwIuk9GlEga45iH8MBmuYAuXmpU= +github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= diff --git a/routing/http/client/client.go b/routing/http/client/client.go index 7c1bb2ac7..ce8851505 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -640,11 +640,11 @@ func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Recor } // GetClosestPeers obtains the closest peers to the given peer ID. -func (c *Client) GetClosestPeers(ctx context.Context, peerID peer.ID) (peers iter.ResultIter[*types.PeerRecord], err error) { +func (c *Client) GetClosestPeers(ctx context.Context, key cid.Cid) (peers iter.ResultIter[*types.PeerRecord], err error) { m := newMeasurement("GetClosestPeers") // Build the base URL path - u, err := gourl.JoinPath(c.baseURL, "routing/v1/dht/closest/peers", peer.ToCid(peerID).String()) + u, err := gourl.JoinPath(c.baseURL, "routing/v1/dht/closest/peers", key.String()) if err != nil { return nil, err } diff --git a/routing/http/client/client_test.go b/routing/http/client/client_test.go index fe992eeea..e3d60dd63 100644 --- a/routing/http/client/client_test.go +++ b/routing/http/client/client_test.go @@ -48,8 +48,8 @@ func (m *mockContentRouter) FindPeers(ctx context.Context, pid peer.ID, limit in return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } -func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) { - args := m.Called(ctx, peerID) +func (m *mockContentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, key) return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } @@ -850,7 +850,7 @@ func TestClient_GetClosestPeers(t *testing.T) { {Val: &httpPeerRecord}, } - pid := *bitswapPeerRecord.ID + key := peer.ToCid(*bitswapPeerRecord.ID) cases := []struct { name string @@ -956,9 +956,9 @@ func TestClient_GetClosestPeers(t *testing.T) { } routerResultIter := iter.FromSlice(c.routerResult) - router.On("GetClosestPeers", mock.Anything, pid).Return(routerResultIter, c.routerErr) + router.On("GetClosestPeers", mock.Anything, key).Return(routerResultIter, c.routerErr) - resultIter, err := client.GetClosestPeers(ctx, pid) + resultIter, err := client.GetClosestPeers(ctx, key) c.expErrContains.errContains(t, err) results := iter.ReadAll(resultIter) diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 06e9656b2..0e0e8b571 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -30,9 +30,9 @@ type Client interface { FindPeers(ctx context.Context, pid peer.ID) (peers iter.ResultIter[*types.PeerRecord], err error) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error - // GetClosestPeers returns the DHT closest peers to the given peer ID. - // If empty, it will use the content router's peer ID (self). - GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) + // GetClosestPeers returns the DHT closest peers to the given Cid key. + // If empty, it should use the content router's peer ID (self). + GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) } type contentRouter struct { @@ -308,8 +308,8 @@ func (c *contentRouter) SearchValue(ctx context.Context, key string, opts ...rou return ch, nil } -func (c *contentRouter) GetClosestPeers(ctx context.Context, pid peer.ID) (<-chan peer.AddrInfo, error) { - iter, err := c.client.GetClosestPeers(ctx, pid) +func (c *contentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (<-chan peer.AddrInfo, error) { + iter, err := c.client.GetClosestPeers(ctx, key) if err != nil { return nil, err } diff --git a/routing/http/contentrouter/contentrouter_test.go b/routing/http/contentrouter/contentrouter_test.go index 98fb2228e..927ee0d44 100644 --- a/routing/http/contentrouter/contentrouter_test.go +++ b/routing/http/contentrouter/contentrouter_test.go @@ -53,8 +53,8 @@ func (m *mockClient) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.R return args.Error(0) } -func (m *mockClient) GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) { - args := m.Called(ctx, peerID) +func (m *mockClient) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, key) return args.Get(0).(iter.ResultIter[*types.PeerRecord]), args.Error(1) } @@ -269,7 +269,7 @@ func TestGetClosestPeers(t *testing.T) { client := &mockClient{} crc := NewContentRoutingClient(client) - peerID := peer.ID("test-peer") + key := makeCID() // Mock response with two peer records peer1 := peer.ID("peer1") @@ -293,9 +293,9 @@ func TestGetClosestPeers(t *testing.T) { peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{peerRec1, peerRec2})) - client.On("GetClosestPeers", ctx, peerID).Return(peerIter, nil) + client.On("GetClosestPeers", ctx, key).Return(peerIter, nil) - infos, err := crc.GetClosestPeers(ctx, peerID) + infos, err := crc.GetClosestPeers(ctx, key) require.NoError(t, err) var actual []peer.AddrInfo @@ -316,7 +316,7 @@ func TestGetClosestPeers(t *testing.T) { client := &mockClient{} crc := NewContentRoutingClient(client) - peerID := peer.ID("test-peer") + key := makeCID() peer1 := peer.ID("peer1") peerRec1 := &types.PeerRecord{ @@ -329,9 +329,9 @@ func TestGetClosestPeers(t *testing.T) { // Mock response with an empty iterator peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{peerRec1})) - client.On("GetClosestPeers", ctx, peerID).Return(peerIter, nil) + client.On("GetClosestPeers", ctx, key).Return(peerIter, nil) - infos, err := crc.GetClosestPeers(ctx, peerID) + infos, err := crc.GetClosestPeers(ctx, key) require.NoError(t, err) var actual []peer.AddrInfo @@ -347,13 +347,13 @@ func TestGetClosestPeers(t *testing.T) { client := &mockClient{} crc := NewContentRoutingClient(client) - peerID := peer.ID("test-peer") + key := makeCID() // Mock error response peerIter := iter.ToResultIter[*types.PeerRecord](iter.FromSlice([]*types.PeerRecord{})) - client.On("GetClosestPeers", ctx, peerID).Return(peerIter, assert.AnError) + client.On("GetClosestPeers", ctx, key).Return(peerIter, assert.AnError) - infos, err := crc.GetClosestPeers(ctx, peerID) + infos, err := crc.GetClosestPeers(ctx, key) require.ErrorIs(t, err, assert.AnError) assert.Nil(t, infos) }) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 66ec66419..7bb5e59d3 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -51,7 +51,7 @@ const ( findProvidersPath = "/routing/v1/providers/{cid}" findPeersPath = "/routing/v1/peers/{peer-id}" getIPNSPath = "/routing/v1/ipns/{cid}" - getClosestPeersPath = "/routing/v1/dht/closest/peers/{peer-id}" + getClosestPeersPath = "/routing/v1/dht/closest/peers/{cid}" ) type FindProvidersAsyncResponse struct { @@ -89,7 +89,7 @@ type DelegatedRouter interface { // GetClosestPeers returns the DHT closest peers to the given peer ID. // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). - GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) + GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) } // ContentRouter is deprecated, use DelegatedRouter instead. @@ -603,10 +603,10 @@ func (s *server) PutIPNS(w http.ResponseWriter, r *http.Request) { } func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { - pidStr := mux.Vars(r)["peer-id"] - pid, err := parsePeerID(pidStr) + cStr := mux.Vars(r)["cid"] + c, err := cid.Decode(cStr) if err != nil { - writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse PeerID %q: %w", pidStr, err)) + writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse CID %q: %w", cStr, err)) return } @@ -628,7 +628,7 @@ func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), s.routingTimeout) defer cancel() - provIter, err := s.svc.GetClosestPeers(ctx, pid) + provIter, err := s.svc.GetClosestPeers(ctx, c) if err != nil { if errors.Is(err, routing.ErrNotFound) { // handlerFunc takes care of setting the 404 and necessary headers diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index a85546219..3a94c920f 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -686,7 +686,7 @@ func TestGetClosestPeers(t *testing.T) { return resp } - t.Run("GET /routing/v1/dht/closest/peers/{non-peerid} returns 400", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{non-cid} returns 400", func(t *testing.T) { t.Parallel() router := &mockContentRouter{} @@ -694,16 +694,17 @@ func TestGetClosestPeers(t *testing.T) { require.Equal(t, 400, resp.StatusCode) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, explicit JSON)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 with correct body and headers (No Results, explicit JSON)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, key).Return(results, nil) - resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String()) + resp := makeRequest(t, router, mediaTypeJSON, key.String()) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) @@ -713,60 +714,64 @@ func TestGetClosestPeers(t *testing.T) { requireCloseToNow(t, resp.Header.Get("Last-Modified")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 with correct body and headers (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, key).Return(results, nil) // Simulate request with Accept header that includes wildcard match - resp := makeRequest(t, router, "text/html,*/*", peer.ToCid(pid).String()) + resp := makeRequest(t, router, "text/html,*/*", key.String()) // Expect response to default to application/json require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, implicit JSON, no Accept header)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 with correct body and headers (No Results, implicit JSON, no Accept header)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, key).Return(results, nil) // Simulate request without Accept header - resp := makeRequest(t, router, "", peer.ToCid(pid).String()) + resp := makeRequest(t, router, "", key.String()) // Expect response to default to application/json require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 when router returns routing.ErrNotFound", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 when router returns routing.ErrNotFound", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(nil, routing.ErrNotFound) + router.On("GetClosestPeers", mock.Anything, key).Return(nil, routing.ErrNotFound) // Simulate request without Accept header - resp := makeRequest(t, router, "", peer.ToCid(pid).String()) + resp := makeRequest(t, router, "", key.String()) // Expect response to default to application/json require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (JSON)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 with correct body and headers (JSON)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ {Val: &types.PeerRecord{ Schema: types.SchemaPeer, @@ -783,10 +788,9 @@ func TestGetClosestPeers(t *testing.T) { }) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, key).Return(results, nil) - libp2pKeyCID := peer.ToCid(pid).String() - resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID) + resp := makeRequest(t, router, mediaTypeJSON, key.String()) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) @@ -802,16 +806,18 @@ func TestGetClosestPeers(t *testing.T) { require.Equal(t, expectedBody, string(body)) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (No Results, NDJSON)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 with correct body and headers (No Results, NDJSON)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) + results := iter.FromSlice([]iter.Result[*types.PeerRecord]{}) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, key).Return(results, nil) - resp := makeRequest(t, router, mediaTypeNDJSON, peer.ToCid(pid).String()) + resp := makeRequest(t, router, mediaTypeNDJSON, key.String()) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) @@ -821,10 +827,11 @@ func TestGetClosestPeers(t *testing.T) { requireCloseToNow(t, resp.Header.Get("Last-Modified")) }) - t.Run("GET /routing/v1/dht/closest/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (NDJSON)", func(t *testing.T) { + t.Run("GET /routing/v1/dht/closest/peers/{cid} returns 200 with correct body and headers (NDJSON)", func(t *testing.T) { t.Parallel() _, pid := makeEd25519PeerID(t) + key := peer.ToCid(pid) results := iter.FromSlice([]iter.Result[*types.PeerRecord]{ {Val: &types.PeerRecord{ Schema: types.SchemaPeer, @@ -841,10 +848,9 @@ func TestGetClosestPeers(t *testing.T) { }) router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(results, nil) + router.On("GetClosestPeers", mock.Anything, key).Return(results, nil) - libp2pKeyCID := peer.ToCid(pid).String() - resp := makeRequest(t, router, mediaTypeNDJSON, libp2pKeyCID) + resp := makeRequest(t, router, mediaTypeNDJSON, key.String()) require.Equal(t, 200, resp.StatusCode) require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) @@ -857,89 +863,6 @@ func TestGetClosestPeers(t *testing.T) { expectedBody := `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"}` + "\n" + `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}` + "\n" require.Equal(t, expectedBody, string(body)) }) - - // Test matrix that runs the HTTP 200 scenario against different flavours of PeerID - // to ensure consistent behavior - peerIdtestCases := []struct { - peerIdType string - makePeerId func(t *testing.T) (crypto.PrivKey, peer.ID) - peerIdAsCidV1 bool - }{ - // Test against current and past PeerID key types and string representations. - // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation - {"cidv1-libp2p-key-ed25519-peerid", makeEd25519PeerID, true}, - {"base58-ed25519-peerid", makeEd25519PeerID, false}, - {"cidv1-libp2p-key-rsa-peerid", makeLegacyRSAPeerID, true}, - {"base58-rsa-peerid", makeLegacyRSAPeerID, false}, - } - - for _, tc := range peerIdtestCases { - _, pid := tc.makePeerId(t) - var peerIDStr string - if tc.peerIdAsCidV1 { - // PeerID represented by CIDv1 with libp2p-key codec - // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation - peerIDStr = peer.ToCid(pid).String() - } else { - // Legacy PeerID starting with "123..." or "Qm.." - // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation - peerIDStr = b58.Encode([]byte(pid)) - } - results := []iter.Result[*types.PeerRecord]{ - {Val: &types.PeerRecord{ - Schema: types.SchemaPeer, - ID: &pid, - Protocols: []string{"transport-bitswap", "transport-foo"}, - Addrs: []types.Multiaddr{}, - }}, - {Val: &types.PeerRecord{ - Schema: types.SchemaPeer, - ID: &pid, - Protocols: []string{"transport-foo"}, - Addrs: []types.Multiaddr{}, - }}, - } - - t.Run("GET /routing/v1/dht/closest/peers/{"+tc.peerIdType+"} returns 200 with correct body and headers (NDJSON streaming response)", func(t *testing.T) { - t.Parallel() - - router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(iter.FromSlice(results), nil) - - resp := makeRequest(t, router, mediaTypeNDJSON, peerIDStr) - require.Equal(t, 200, resp.StatusCode) - - require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type")) - require.Equal(t, "Accept", resp.Header.Get("Vary")) - require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - expectedBody := `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"}` + "\n" + `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}` + "\n" - require.Equal(t, expectedBody, string(body)) - }) - - t.Run("GET /routing/v1/dht/closest/peers/{"+tc.peerIdType+"} returns 200 with correct body and headers (JSON response)", func(t *testing.T) { - t.Parallel() - - router := &mockContentRouter{} - router.On("GetClosestPeers", mock.Anything, pid).Return(iter.FromSlice(results), nil) - - resp := makeRequest(t, router, mediaTypeJSON, peerIDStr) - require.Equal(t, 200, resp.StatusCode) - - require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) - require.Equal(t, "Accept", resp.Header.Get("Vary")) - require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - expectedBody := `{"Peers":[{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"},{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}]}` - require.Equal(t, expectedBody, string(body)) - }) - } } func makeName(t *testing.T) (crypto.PrivKey, ipns.Name) { @@ -1204,8 +1127,8 @@ func (m *mockContentRouter) PutIPNS(ctx context.Context, name ipns.Name, record return args.Error(0) } -func (m *mockContentRouter) GetClosestPeers(ctx context.Context, peerID peer.ID) (iter.ResultIter[*types.PeerRecord], error) { - args := m.Called(ctx, peerID) +func (m *mockContentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) { + args := m.Called(ctx, key) a := args.Get(0) if a == nil { return nil, args.Error(1) From 713b7c02471cb20f9287f143cd2bbc34d1771a03 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Oct 2025 17:42:47 +0200 Subject: [PATCH 13/17] routing/http: accept CID or PeerID for GetClosestPeers - parseKey() utility tries peer.Decode() first, then cid.Decode() - tests use hardcoded examples from libp2p specs - tests verify digest matching (not exact CID) via hex-encoded values - only the multihash digest matters for DHT operations, not CID codec - error messages include both parse failures for better debugging --- routing/http/client/client.go | 2 +- routing/http/contentrouter/contentrouter.go | 3 +- routing/http/server/server.go | 40 +++++- routing/http/server/server_test.go | 142 ++++++++++++++++++++ 4 files changed, 178 insertions(+), 9 deletions(-) diff --git a/routing/http/client/client.go b/routing/http/client/client.go index ce8851505..d12a789ae 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -639,7 +639,7 @@ func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Recor return nil } -// GetClosestPeers obtains the closest peers to the given peer ID. +// GetClosestPeers obtains the closest peers to the given key (CID or Peer ID). func (c *Client) GetClosestPeers(ctx context.Context, key cid.Cid) (peers iter.ResultIter[*types.PeerRecord], err error) { m := newMeasurement("GetClosestPeers") diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 0e0e8b571..34049a1e1 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -30,8 +30,7 @@ type Client interface { FindPeers(ctx context.Context, pid peer.ID) (peers iter.ResultIter[*types.PeerRecord], err error) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error - // GetClosestPeers returns the DHT closest peers to the given Cid key. - // If empty, it should use the content router's peer ID (self). + // GetClosestPeers returns the DHT closest peers to the given key (CID or Peer ID). GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) } diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 7bb5e59d3..9a74864f6 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -51,7 +51,7 @@ const ( findProvidersPath = "/routing/v1/providers/{cid}" findPeersPath = "/routing/v1/peers/{peer-id}" getIPNSPath = "/routing/v1/ipns/{cid}" - getClosestPeersPath = "/routing/v1/dht/closest/peers/{cid}" + getClosestPeersPath = "/routing/v1/dht/closest/peers/{key}" ) type FindProvidersAsyncResponse struct { @@ -87,8 +87,7 @@ type DelegatedRouter interface { // It is guaranteed that the record matches the provided name. PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error - // GetClosestPeers returns the DHT closest peers to the given peer ID. - // If empty, it will use the content router's peer ID (self). `closerThan` (optional) forces resulting records to be closer to `PeerID` than to `closerThan`. `count` specifies how many records to return ([1,100], with 20 as default when set to 0). + // GetClosestPeers returns the DHT closest peers to the given key (CID or Peer ID). GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) } @@ -603,10 +602,10 @@ func (s *server) PutIPNS(w http.ResponseWriter, r *http.Request) { } func (s *server) getClosestPeers(w http.ResponseWriter, r *http.Request) { - cStr := mux.Vars(r)["cid"] - c, err := cid.Decode(cStr) + keyStr := mux.Vars(r)["key"] + c, err := parseKey(keyStr) if err != nil { - writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse CID %q: %w", cStr, err)) + writeErr(w, "GetClosestPeers", http.StatusBadRequest, fmt.Errorf("unable to parse key %q: %w", keyStr, err)) return } @@ -694,6 +693,35 @@ func parsePeerID(pidStr string) (peer.ID, error) { return pid, err } +// parseKey parses a string that can be either a CID or a PeerID. +// It accepts the following formats: +// - Arbitrary CIDs (e.g., bafkreidcd7frenco2m6ch7mny63wztgztv3q6fctaffgowkro6kljre5ei) +// - CIDv1 with libp2p-key codec (e.g., bafzaajaiaejca...) +// - Base58-encoded PeerIDs (e.g., 12D3KooW... or QmYyQ...) +// +// This function is used by endpoints that accept "key" path parameters, where +// the key can represent either content (CID) or a peer (PeerID). +// +// Returns the key as a CID. PeerIDs are converted to CIDv1 with libp2p-key codec. +// Note: only use where the multihash digest of the returned CID is relevant. +func parseKey(keyStr string) (cid.Cid, error) { + // Try parsing as PeerID first using peer.Decode (not parsePeerID, which is too liberal) + // This handles legacy PeerID formats per: + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + pid, pidErr := peer.Decode(keyStr) + if pidErr == nil { + return peer.ToCid(pid), nil + } + + // Fall back to parsing as CID (handles arbitrary CIDs and CIDv1 libp2p-key format) + c, cidErr := cid.Decode(keyStr) + if cidErr == nil { + return c, nil + } + + return cid.Cid{}, fmt.Errorf("unable to parse as CID or PeerID: %w", errors.Join(cidErr, pidErr)) +} + func setCacheControl(w http.ResponseWriter, maxAge int, stale int) { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d, stale-if-error=%d", maxAge, stale, stale)) } diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index 3a94c920f..cc17cb5bf 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -668,6 +668,78 @@ func TestPeers(t *testing.T) { } } +func TestParseKey(t *testing.T) { + t.Run("parses arbitrary CID", func(t *testing.T) { + cidStr := "bafkreidcd7frenco2m6ch7mny63wztgztv3q6fctaffgowkro6kljre5ei" + expectedCID, err := cid.Decode(cidStr) + require.NoError(t, err) + + parsedCID, err := parseKey(cidStr) + require.NoError(t, err) + require.Equal(t, expectedCID, parsedCID) + }) + + t.Run("parses Ed25519 PeerID as CIDv1 libp2p-key", func(t *testing.T) { + // Example from libp2p specs + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + cidStr := "bafzbeie5745rpv2m6tjyuugywy4d5ewrqgqqhfnf445he3omzpjbx5xqxe" + pid, err := peer.Decode(cidStr) + require.NoError(t, err) + expectedCID := peer.ToCid(pid) + + parsedCID, err := parseKey(cidStr) + require.NoError(t, err) + require.Equal(t, expectedCID, parsedCID) + }) + + t.Run("parses Ed25519 PeerID as Base58", func(t *testing.T) { + // Example from libp2p specs (identity multihash) + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + pidStr := "12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA" + pid, err := peer.Decode(pidStr) + require.NoError(t, err) + expectedCID := peer.ToCid(pid) + + parsedCID, err := parseKey(pidStr) + require.NoError(t, err) + require.Equal(t, expectedCID, parsedCID) + }) + + t.Run("parses RSA PeerID as CIDv1 libp2p-key", func(t *testing.T) { + // RSA PeerID starting with "Qm" encoded as CIDv1 + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + pidStr := "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N" + pid, err := peer.Decode(pidStr) + require.NoError(t, err) + // Convert to CIDv1 representation + cidStr := peer.ToCid(pid).String() + expectedCID := peer.ToCid(pid) + + parsedCID, err := parseKey(cidStr) + require.NoError(t, err) + require.Equal(t, expectedCID, parsedCID) + }) + + t.Run("parses RSA PeerID as Base58", func(t *testing.T) { + // Example from libp2p specs (SHA256-based) + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + pidStr := "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N" + pid, err := peer.Decode(pidStr) + require.NoError(t, err) + expectedCID := peer.ToCid(pid) + + parsedCID, err := parseKey(pidStr) + require.NoError(t, err) + require.Equal(t, expectedCID, parsedCID) + }) + + t.Run("returns error for invalid string", func(t *testing.T) { + _, err := parseKey("not-a-valid-cid-or-peerid") + require.Error(t, err) + require.Contains(t, err.Error(), "unable to parse as CID or PeerID") + }) +} + func TestGetClosestPeers(t *testing.T) { makeRequest := func(t *testing.T, router *mockContentRouter, contentType, arg string) *http.Response { server := httptest.NewServer(Handler(router)) @@ -863,6 +935,76 @@ func TestGetClosestPeers(t *testing.T) { expectedBody := `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"}` + "\n" + `{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}` + "\n" require.Equal(t, expectedBody, string(body)) }) + + // Test matrix that runs the HTTP 200 scenario against different key formats. + // The test verifies that GetClosestPeers is called with a CID whose digest matches the expected value, + // regardless of the CID codec. This is correct because DHT operations only use the digest. + // per https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + keyTestCases := []struct { + keyType string + keyStr string + expectedDigest string // hex-encoded multihash digest + }{ + // Examples from libp2p spec + // https://github.com/libp2p/specs/blob/master/peer-ids/peer-ids.md#string-representation + {"cidv1-libp2p-key-ed25519-peerid", "bafzbeie5745rpv2m6tjyuugywy4d5ewrqgqqhfnf445he3omzpjbx5xqxe", "12209dff3b17d74cf4d38a50d8b6383e92d181a10395a5e73a726dcccbd21bf6f0b9"}, + {"base58-ed25519-peerid", "12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA", "0024080112202ffa35a99d3a3cfbb17bb7c1dc5561b18a8dcca4df38dc613ea859c37eb1336b"}, + {"base58-rsa-peerid", "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N", "12209dff3b17d74cf4d38a50d8b6383e92d181a10395a5e73a726dcccbd21bf6f0b9"}, + // Arbitrary CID (not a PeerID) + {"arbitrary-cid", "bafkreidcd7frenco2m6ch7mny63wztgztv3q6fctaffgowkro6kljre5ei", "1220621fcb12344ed33c23fd8dc7b76cccd99d770f1453014a6759517794b4c49d22"}, + } + + for _, tc := range keyTestCases { + // Parse the key to get the actual digest + parsedKey, err := parseKey(tc.keyStr) + require.NoError(t, err) + actualDigest := parsedKey.Hash() + + // Verify it matches expected + require.Equal(t, tc.expectedDigest, actualDigest.HexString()) + + // Create a PeerID from the digest for response records + pid, err := peer.IDFromBytes(actualDigest) + require.NoError(t, err) + + results := []iter.Result[*types.PeerRecord]{ + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-bitswap", "transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + {Val: &types.PeerRecord{ + Schema: types.SchemaPeer, + ID: &pid, + Protocols: []string{"transport-foo"}, + Addrs: []types.Multiaddr{}, + }}, + } + + t.Run("GET /routing/v1/dht/closest/peers/{"+tc.keyType+"} returns 200 with correct body and headers (JSON)", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + // Use mock.MatchedBy to verify the digest matches, regardless of codec + router.On("GetClosestPeers", mock.Anything, mock.MatchedBy(func(key cid.Cid) bool { + return bytes.Equal(key.Hash(), actualDigest) + })).Return(iter.FromSlice(results), nil) + + resp := makeRequest(t, router, mediaTypeJSON, tc.keyStr) + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type")) + require.Equal(t, "Accept", resp.Header.Get("Vary")) + require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedBody := `{"Peers":[{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-bitswap","transport-foo"],"Schema":"peer"},{"Addrs":[],"ID":"` + pid.String() + `","Protocols":["transport-foo"],"Schema":"peer"}]}` + require.Equal(t, expectedBody, string(body)) + }) + } } func makeName(t *testing.T) (crypto.PrivKey, ipns.Name) { From ce6837eb264bafe17e2c242bee0d1c28a242b1f3 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Oct 2025 18:05:45 +0200 Subject: [PATCH 14/17] docs: add GetClosestPeers changes to CHANGELOG - routing/http/server accepts CID or PeerID for GetClosestPeers - references IPIP-476 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3837e52a8..411f7bdd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The following emojis are used to highlight certain changes: ### Added +- `routing/http`: `GET /routing/v1/dht/closest/peers/{key}` per [IPIP-476](https://github.com/ipfs/specs/pull/476) + ### Changed - upgrade to `go-libp2p` [v0.44.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.44.0) From 32564011933880cb52399e73c9da46fd927b7c7b Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 17 Oct 2025 19:13:47 +0200 Subject: [PATCH 15/17] fix(routing/http): correct method name in getClosestPeersJSON logging fixes copy-paste error where GetClosestPeers endpoint was logging errors as "FindPeers" instead of "GetClosestPeers" --- routing/http/server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routing/http/server/server.go b/routing/http/server/server.go index 9a74864f6..ceb75b819 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -648,7 +648,7 @@ func (s *server) getClosestPeersJSON(w http.ResponseWriter, peersIter iter.Resul return } - writeJSONResult(w, "FindPeers", jsontypes.PeersResponse{ + writeJSONResult(w, "GetClosestPeers", jsontypes.PeersResponse{ Peers: peers, }) } From 86f6bf2b19a298fbc83b78b1aa76c3da9f5173bb Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Wed, 12 Nov 2025 12:02:03 +0100 Subject: [PATCH 16/17] move DHTRouter interface to contentrouter from routing-helpers --- go.mod | 2 +- go.sum | 2 ++ routing/http/contentrouter/contentrouter.go | 6 +++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index ff4353f05..0f4e747d1 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/libp2p/go-libp2p v0.45.0 github.com/libp2p/go-libp2p-kad-dht v0.35.1 github.com/libp2p/go-libp2p-record v0.3.1 - github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e + github.com/libp2p/go-libp2p-routing-helpers v0.7.5 github.com/libp2p/go-libp2p-testing v0.12.0 github.com/libp2p/go-msgio v0.3.0 github.com/miekg/dns v1.1.68 diff --git a/go.sum b/go.sum index 2d7da1d44..86c5028af 100644 --- a/go.sum +++ b/go.sum @@ -226,6 +226,8 @@ github.com/libp2p/go-libp2p-kbucket v0.8.0 h1:QAK7RzKJpYe+EuSEATAaaHYMYLkPDGC18m github.com/libp2p/go-libp2p-kbucket v0.8.0/go.mod h1:JMlxqcEyKwO6ox716eyC0hmiduSWZZl6JY93mGaaqc4= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= +github.com/libp2p/go-libp2p-routing-helpers v0.7.5 h1:HdwZj9NKovMx0vqq6YNPTh6aaNzey5zHD7HeLJtq6fI= +github.com/libp2p/go-libp2p-routing-helpers v0.7.5/go.mod h1:3YaxrwP0OBPDD7my3D0KxfR89FlcX/IEbxDEDfAmj98= github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e h1:6DSfN9gsAmBa1iyAKwIuk9GlEga45iH8MBmuYAuXmpU= github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= diff --git a/routing/http/contentrouter/contentrouter.go b/routing/http/contentrouter/contentrouter.go index 34049a1e1..a781be23f 100644 --- a/routing/http/contentrouter/contentrouter.go +++ b/routing/http/contentrouter/contentrouter.go @@ -40,13 +40,17 @@ type contentRouter struct { maxProvideBatchSize int } +type DHTRouter interface { + GetClosestPeers(context.Context, cid.Cid) (<-chan peer.AddrInfo, error) +} + var ( _ routing.ContentRouting = (*contentRouter)(nil) _ routing.PeerRouting = (*contentRouter)(nil) _ routing.ValueStore = (*contentRouter)(nil) _ routinghelpers.ProvideManyRouter = (*contentRouter)(nil) _ routinghelpers.ReadyAbleRouter = (*contentRouter)(nil) - _ routinghelpers.DHTRouter = (*contentRouter)(nil) + _ DHTRouter = (*contentRouter)(nil) ) type option func(c *contentRouter) From 17f5746dfbb7dd36196ae23d59939c2fa7d98236 Mon Sep 17 00:00:00 2001 From: Hector Sanjuan Date: Wed, 12 Nov 2025 12:12:59 +0100 Subject: [PATCH 17/17] go mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 86c5028af..c60b34be8 100644 --- a/go.sum +++ b/go.sum @@ -228,8 +228,6 @@ github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOU github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= github.com/libp2p/go-libp2p-routing-helpers v0.7.5 h1:HdwZj9NKovMx0vqq6YNPTh6aaNzey5zHD7HeLJtq6fI= github.com/libp2p/go-libp2p-routing-helpers v0.7.5/go.mod h1:3YaxrwP0OBPDD7my3D0KxfR89FlcX/IEbxDEDfAmj98= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e h1:6DSfN9gsAmBa1iyAKwIuk9GlEga45iH8MBmuYAuXmpU= -github.com/libp2p/go-libp2p-routing-helpers v0.7.6-0.20251016083611-f098f492895e/go.mod h1:Q1VSaOawgsvaa3hGl/PejADIhl2deiqSEsQDpB3Ggss= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0=