Skip to content

Commit 7eb3ea2

Browse files
committed
feat: implement IPIP-513 for routing v1 endpoints
changes: - server returns 200 instead of 404 for empty results - providers: empty JSON/NDJSON array - peers: empty JSON/NDJSON array - ipns: text/plain "Record not found" - client handles both 200 and 404 responses for backward compatibility - client handles null/undefined Providers/Peers fields test coverage: - added tests for 404 backward compatibility (old servers) - added tests for null/undefined field handling - added tests for IPNS content-type validation - test fixtures use special CIDs to trigger edge cases
1 parent 00aceb9 commit 7eb3ea2

File tree

10 files changed

+407
-71
lines changed

10 files changed

+407
-71
lines changed

packages/client/.aegir.js

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import EchoServer from 'aegir/echo-server'
22
import body from 'body-parser'
33

4+
// Special test CIDs that trigger specific fixtures
5+
const TEST_CIDS = {
6+
// Providers endpoint test CIDs
7+
PROVIDERS_404: 'bafkreig3o4e7r4bpsc3hqirlzjeuie3w25tfjgmp6ufeaabwvuial3r4h4', // return404providers
8+
PROVIDERS_NULL: 'bafkreicyicgkpqid2qs3kfc277f4tsx5tew3e63fgv7fn6t74sicjkv76i', // returnnullproviders
9+
10+
// Peers endpoint test CIDs (libp2p-key format)
11+
PEERS_404: 'k2k4r8pqu6ui9p0d0fewul7462tsb0pa57pi238gunrjxpfrg6zawrho', // return404peers
12+
PEERS_NULL: 'k2k4r8nyb48mv6n6olsob1zsz77mhdrvwtjcryjil2qqqzye5jds4uur', // returnnullpeers
13+
14+
// IPNS endpoint test CIDs (libp2p-key format)
15+
IPNS_404: 'k2k4r8o3937xct4wma8gooitiip4mik0phkg8kt3b5x9y93a9dntvwjz', // return404ipns
16+
IPNS_JSON: 'k2k4r8pajj9txni0h9nv9gxuj1mju4jmi94iq2r4jwhxk87hnuo94yom', // returnjsonipns
17+
IPNS_HTML: 'k2k4r8kddkyieizgq7a32d9jc4nm99yupniet962vssrm34hamolquzk', // returnhtmlipns
18+
IPNS_NO_CONTENT_TYPE: 'k2k4r8okqrya8gr449btdy5b6vw0q68dh7y3fps9qbi0zmcmybz7bjpu' // returnnocontentipns
19+
}
20+
421
/** @type {import('aegir').PartialOptions} */
522
const options = {
623
test: {
@@ -47,16 +64,32 @@ const options = {
4764
echo.polka.get('/routing/v1/providers/:cid', (req, res) => {
4865
callCount++
4966
try {
50-
const providerData = providers.get(req.params.cid) || { Providers: [] }
67+
const providerData = providers.get(req.params.cid)
68+
69+
// Support testing 404 responses for backward compatibility
70+
if (req.params.cid === TEST_CIDS.PROVIDERS_404) {
71+
res.statusCode = 404
72+
res.end('Not Found')
73+
return
74+
}
75+
76+
// Support testing null Providers field
77+
if (req.params.cid === TEST_CIDS.PROVIDERS_NULL) {
78+
res.setHeader('Content-Type', 'application/json')
79+
res.end(JSON.stringify({ Providers: null }))
80+
return
81+
}
82+
5183
const acceptHeader = req.headers.accept
84+
const data = providerData || { Providers: [] }
5285

5386
if (acceptHeader?.includes('application/x-ndjson')) {
5487
res.setHeader('Content-Type', 'application/x-ndjson')
55-
const providers = Array.isArray(providerData.Providers) ? providerData.Providers : []
88+
const providers = Array.isArray(data.Providers) ? data.Providers : []
5689
res.end(providers.map(p => JSON.stringify(p)).join('\n'))
5790
} else {
5891
res.setHeader('Content-Type', 'application/json')
59-
res.end(JSON.stringify(providerData))
92+
res.end(JSON.stringify(data))
6093
}
6194
} catch (err) {
6295
console.error('Error in get providers:', err)
@@ -71,10 +104,36 @@ const options = {
71104
})
72105
echo.polka.get('/routing/v1/peers/:peerId', (req, res) => {
73106
callCount++
74-
const records = peers.get(req.params.peerId) ?? '[]'
75-
peers.delete(req.params.peerId)
76107

77-
res.end(records)
108+
// Support testing 404 responses for backward compatibility
109+
if (req.params.peerId === TEST_CIDS.PEERS_404) {
110+
res.statusCode = 404
111+
res.end('Not Found')
112+
return
113+
}
114+
115+
// Support testing null Peers field
116+
if (req.params.peerId === TEST_CIDS.PEERS_NULL) {
117+
res.setHeader('Content-Type', 'application/json')
118+
res.end(JSON.stringify({ Peers: null }))
119+
return
120+
}
121+
122+
const records = peers.get(req.params.peerId)
123+
if (records) {
124+
peers.delete(req.params.peerId)
125+
res.end(records)
126+
} else {
127+
// Return empty JSON response
128+
const acceptHeader = req.headers.accept
129+
if (acceptHeader?.includes('application/x-ndjson')) {
130+
res.setHeader('Content-Type', 'application/x-ndjson')
131+
res.end('')
132+
} else {
133+
res.setHeader('Content-Type', 'application/json')
134+
res.end(JSON.stringify({ Peers: [] }))
135+
}
136+
}
78137
})
79138
echo.polka.post('/add-ipns/:peerId', (req, res) => {
80139
callCount++
@@ -83,10 +142,42 @@ const options = {
83142
})
84143
echo.polka.get('/routing/v1/ipns/:peerId', (req, res) => {
85144
callCount++
86-
const record = ipnsGet.get(req.params.peerId) ?? ''
145+
const record = ipnsGet.get(req.params.peerId)
87146
ipnsGet.delete(req.params.peerId)
88147

89-
res.end(record)
148+
// Support testing different content-types
149+
if (req.params.peerId === TEST_CIDS.IPNS_404) {
150+
res.statusCode = 404
151+
res.end('Not Found')
152+
return
153+
}
154+
155+
if (req.params.peerId === TEST_CIDS.IPNS_JSON) {
156+
res.setHeader('Content-Type', 'application/json')
157+
res.end(JSON.stringify({ error: 'not found' }))
158+
return
159+
}
160+
161+
if (req.params.peerId === TEST_CIDS.IPNS_HTML) {
162+
res.setHeader('Content-Type', 'text/html')
163+
res.end('<html>Not Found</html>')
164+
return
165+
}
166+
167+
if (req.params.peerId === TEST_CIDS.IPNS_NO_CONTENT_TYPE) {
168+
// No content-type header
169+
res.end('No record')
170+
return
171+
}
172+
173+
if (record) {
174+
res.setHeader('Content-Type', 'application/vnd.ipfs.ipns-record')
175+
res.end(record)
176+
} else {
177+
// Per IPIP-0513: Return 200 with text/plain for no record found
178+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
179+
res.end('Record not found')
180+
}
90181
})
91182
echo.polka.put('/routing/v1/ipns/:peerId', (req, res) => {
92183
callCount++

packages/client/src/client.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,14 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
123123
const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal }
124124
const res = await this.#makeRequest(url.toString(), getOptions)
125125

126-
if (res == null) {
127-
throw new BadResponseError('No response received')
126+
// Per IPIP-0513: Handle 404 as empty results (not an error)
127+
// Old servers return 404, new servers return 200 with empty array
128+
// Both should result in an empty iterator, not an error
129+
if (res.status === 404) {
130+
return // Return empty iterator
128131
}
129-
if (!res.ok) {
130-
if (res.status === 404) {
131-
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
132-
// 404 (Not Found): must be returned if no matching records are found
133-
throw new NotFoundError('No matching records found')
134-
}
135132

133+
if (!res.ok) {
136134
if (res.status === 422) {
137135
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
138136
// 422 (Unprocessable Entity): request does not conform to schema or semantic constraints
@@ -150,10 +148,12 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
150148
throw new BadResponseError('No Content-Type header received')
151149
}
152150

153-
if (contentType?.startsWith('application/json')) {
151+
if (contentType.startsWith('application/json')) {
154152
const body = await res.json()
153+
// Handle null/undefined Providers from servers (both old and new may return empty arrays)
154+
const providers = body.Providers ?? []
155155

156-
for (const provider of body.Providers) {
156+
for (const provider of providers) {
157157
const record = this.#conformToPeerSchema(provider)
158158
if (record != null) {
159159
yield record
@@ -200,10 +200,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
200200
const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal }
201201
const res = await this.#makeRequest(url.toString(), getOptions)
202202

203+
// Per IPIP-0513: Handle 404 as empty results (not an error)
204+
// Old servers return 404, new servers return 200 with empty array
205+
// Both should result in an empty iterator, not an error
203206
if (res.status === 404) {
204-
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
205-
// 404 (Not Found): must be returned if no matching records are found.
206-
throw new NotFoundError('No matching records found')
207+
return // Return empty iterator
207208
}
208209

209210
if (res.status === 422) {
@@ -217,10 +218,12 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
217218
}
218219

219220
const contentType = res.headers.get('Content-Type')
220-
if (contentType === 'application/json') {
221+
if (contentType?.startsWith('application/json')) {
221222
const body = await res.json()
223+
// Handle null/undefined Peers from servers (both old and new may return empty arrays)
224+
const peers = body.Peers ?? []
222225

223-
for (const peer of body.Peers) {
226+
for (const peer of peers) {
224227
const record = this.#conformToPeerSchema(peer)
225228
if (record != null) {
226229
yield record
@@ -268,9 +271,10 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
268271

269272
log('getIPNS GET %s %d', resource, res.status)
270273

274+
// Per IPIP-0513: Handle 404 as "no record found" for backward compatibility
275+
// IPNS is different - we still throw NotFoundError for 404 (backward compat)
276+
// and also for 200 with non-IPNS content type (new behavior)
271277
if (res.status === 404) {
272-
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
273-
// 404 (Not Found): must be returned if no matching records are found
274278
throw new NotFoundError('No matching records found')
275279
}
276280

@@ -280,6 +284,17 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
280284
throw new InvalidRequestError('Request does not conform to schema or semantic constraints')
281285
}
282286

287+
if (!res.ok) {
288+
throw new BadResponseError(`Unexpected status code: ${res.status}`)
289+
}
290+
291+
// Per IPIP-0513: Only Content-Type: application/vnd.ipfs.ipns-record indicates a valid record
292+
// ANY other content type (or missing content-type) means no record found
293+
const contentType = res.headers.get('Content-Type')
294+
if (contentType == null || !contentType.includes('application/vnd.ipfs.ipns-record')) {
295+
throw new NotFoundError('No matching records found')
296+
}
297+
283298
if (res.body == null) {
284299
throw new BadResponseError('GET ipns response had no body')
285300
}

0 commit comments

Comments
 (0)