Skip to content

Commit a0aebb7

Browse files
lidelachingbrain
andauthored
feat(routing/http): return 200 for empty results per IPIP-513 (#174)
Implements IPIP-513 spec changes for delegated routing v1 endpoints to return 200 with empty results instead of 404 + some extra fixes to ensure client interop with broad type of servers. 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 --------- Co-authored-by: achingbrain <[email protected]>
1 parent 44c84db commit a0aebb7

File tree

9 files changed

+404
-67
lines changed

9 files changed

+404
-67
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: {
@@ -45,8 +62,24 @@ const options = {
4562
echo.polka.get('/routing/v1/providers/:cid', (req, res) => {
4663
callCount++
4764
try {
48-
const providerData = providers.get(req.params.cid) || { Providers: [] }
65+
const providerData = providers.get(req.params.cid)
66+
67+
// Support testing 404 responses for backward compatibility
68+
if (req.params.cid === TEST_CIDS.PROVIDERS_404) {
69+
res.statusCode = 404
70+
res.end('Not Found')
71+
return
72+
}
73+
74+
// Support testing null Providers field
75+
if (req.params.cid === TEST_CIDS.PROVIDERS_NULL) {
76+
res.setHeader('Content-Type', 'application/json')
77+
res.end(JSON.stringify({ Providers: null }))
78+
return
79+
}
80+
4981
const acceptHeader = req.headers.accept
82+
const data = providerData || { Providers: [] }
5083

5184
if (providerData?.Providers?.length === 0) {
5285
res.statusCode = 404
@@ -56,11 +89,11 @@ const options = {
5689

5790
if (acceptHeader?.includes('application/x-ndjson')) {
5891
res.setHeader('Content-Type', 'application/x-ndjson')
59-
const providers = Array.isArray(providerData.Providers) ? providerData.Providers : []
92+
const providers = Array.isArray(data.Providers) ? data.Providers : []
6093
res.end(providers.map(p => JSON.stringify(p)).join('\n'))
6194
} else {
6295
res.setHeader('Content-Type', 'application/json')
63-
res.end(JSON.stringify(providerData))
96+
res.end(JSON.stringify(data))
6497
}
6598
} catch (err) {
6699
console.error('Error in get providers:', err)
@@ -75,10 +108,36 @@ const options = {
75108
})
76109
echo.polka.get('/routing/v1/peers/:peerId', (req, res) => {
77110
callCount++
78-
const records = peers.get(req.params.peerId) ?? '[]'
79-
peers.delete(req.params.peerId)
80111

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

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

packages/client/src/client.ts

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

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

138136
if (res.status === 422) {
139137
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
140138
// 422 (Unprocessable Entity): request does not conform to schema or semantic constraints
141139
throw new InvalidRequestError('Request does not conform to schema or semantic constraints')
142140
}
141+
143142
throw new BadResponseError(`Unexpected status code: ${res.status}`)
144143
}
145144

@@ -152,10 +151,12 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
152151
throw new BadResponseError('No Content-Type header received')
153152
}
154153

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

158-
for (const provider of body.Providers) {
159+
for (const provider of providers) {
159160
const record = this.#conformToPeerSchema(provider)
160161
if (record != null) {
161162
yield record
@@ -202,10 +203,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
202203
const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal }
203204
const res = await this.#makeRequest(url.toString(), getOptions)
204205

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

211213
if (res.status === 422) {
@@ -219,10 +221,12 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
219221
}
220222

221223
const contentType = res.headers.get('Content-Type')
222-
if (contentType === 'application/json') {
224+
if (contentType?.startsWith('application/json')) {
223225
const body = await res.json()
226+
// Handle null/undefined Peers from servers (both old and new may return empty arrays)
227+
const peers = body.Peers ?? []
224228

225-
for (const peer of body.Peers) {
229+
for (const peer of peers) {
226230
const record = this.#conformToPeerSchema(peer)
227231
if (record != null) {
228232
yield record
@@ -270,9 +274,10 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
270274

271275
log('getIPNS GET %s %d', resource, res.status)
272276

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

@@ -282,6 +287,17 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
282287
throw new InvalidRequestError('Request does not conform to schema or semantic constraints')
283288
}
284289

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

0 commit comments

Comments
 (0)