diff --git a/packages/client/.aegir.js b/packages/client/.aegir.js index 1aeb139..012c9ba 100644 --- a/packages/client/.aegir.js +++ b/packages/client/.aegir.js @@ -1,6 +1,23 @@ import EchoServer from 'aegir/echo-server' import body from 'body-parser' +// Special test CIDs that trigger specific fixtures +const TEST_CIDS = { + // Providers endpoint test CIDs + PROVIDERS_404: 'bafkreig3o4e7r4bpsc3hqirlzjeuie3w25tfjgmp6ufeaabwvuial3r4h4', // return404providers + PROVIDERS_NULL: 'bafkreicyicgkpqid2qs3kfc277f4tsx5tew3e63fgv7fn6t74sicjkv76i', // returnnullproviders + + // Peers endpoint test CIDs (libp2p-key format) + PEERS_404: 'k2k4r8pqu6ui9p0d0fewul7462tsb0pa57pi238gunrjxpfrg6zawrho', // return404peers + PEERS_NULL: 'k2k4r8nyb48mv6n6olsob1zsz77mhdrvwtjcryjil2qqqzye5jds4uur', // returnnullpeers + + // IPNS endpoint test CIDs (libp2p-key format) + IPNS_404: 'k2k4r8o3937xct4wma8gooitiip4mik0phkg8kt3b5x9y93a9dntvwjz', // return404ipns + IPNS_JSON: 'k2k4r8pajj9txni0h9nv9gxuj1mju4jmi94iq2r4jwhxk87hnuo94yom', // returnjsonipns + IPNS_HTML: 'k2k4r8kddkyieizgq7a32d9jc4nm99yupniet962vssrm34hamolquzk', // returnhtmlipns + IPNS_NO_CONTENT_TYPE: 'k2k4r8okqrya8gr449btdy5b6vw0q68dh7y3fps9qbi0zmcmybz7bjpu' // returnnocontentipns +} + /** @type {import('aegir').PartialOptions} */ const options = { test: { @@ -47,16 +64,32 @@ const options = { echo.polka.get('/routing/v1/providers/:cid', (req, res) => { callCount++ try { - const providerData = providers.get(req.params.cid) || { Providers: [] } + const providerData = providers.get(req.params.cid) + + // Support testing 404 responses for backward compatibility + if (req.params.cid === TEST_CIDS.PROVIDERS_404) { + res.statusCode = 404 + res.end('Not Found') + return + } + + // Support testing null Providers field + if (req.params.cid === TEST_CIDS.PROVIDERS_NULL) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ Providers: null })) + return + } + const acceptHeader = req.headers.accept + const data = providerData || { Providers: [] } if (acceptHeader?.includes('application/x-ndjson')) { res.setHeader('Content-Type', 'application/x-ndjson') - const providers = Array.isArray(providerData.Providers) ? providerData.Providers : [] + const providers = Array.isArray(data.Providers) ? data.Providers : [] res.end(providers.map(p => JSON.stringify(p)).join('\n')) } else { res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(providerData)) + res.end(JSON.stringify(data)) } } catch (err) { console.error('Error in get providers:', err) @@ -71,10 +104,36 @@ const options = { }) echo.polka.get('/routing/v1/peers/:peerId', (req, res) => { callCount++ - const records = peers.get(req.params.peerId) ?? '[]' - peers.delete(req.params.peerId) - res.end(records) + // Support testing 404 responses for backward compatibility + if (req.params.peerId === TEST_CIDS.PEERS_404) { + res.statusCode = 404 + res.end('Not Found') + return + } + + // Support testing null Peers field + if (req.params.peerId === TEST_CIDS.PEERS_NULL) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ Peers: null })) + return + } + + const records = peers.get(req.params.peerId) + if (records) { + peers.delete(req.params.peerId) + res.end(records) + } else { + // Return empty JSON response + const acceptHeader = req.headers.accept + if (acceptHeader?.includes('application/x-ndjson')) { + res.setHeader('Content-Type', 'application/x-ndjson') + res.end('') + } else { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ Peers: [] })) + } + } }) echo.polka.post('/add-ipns/:peerId', (req, res) => { callCount++ @@ -83,10 +142,42 @@ const options = { }) echo.polka.get('/routing/v1/ipns/:peerId', (req, res) => { callCount++ - const record = ipnsGet.get(req.params.peerId) ?? '' + const record = ipnsGet.get(req.params.peerId) ipnsGet.delete(req.params.peerId) - res.end(record) + // Support testing different content-types + if (req.params.peerId === TEST_CIDS.IPNS_404) { + res.statusCode = 404 + res.end('Not Found') + return + } + + if (req.params.peerId === TEST_CIDS.IPNS_JSON) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'not found' })) + return + } + + if (req.params.peerId === TEST_CIDS.IPNS_HTML) { + res.setHeader('Content-Type', 'text/html') + res.end('Not Found') + return + } + + if (req.params.peerId === TEST_CIDS.IPNS_NO_CONTENT_TYPE) { + // No content-type header + res.end('No record') + return + } + + if (record) { + res.setHeader('Content-Type', 'application/vnd.ipfs.ipns-record') + res.end(record) + } else { + // Per IPIP-0513: Return 200 with text/plain for no record found + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.end('Record not found') + } }) echo.polka.put('/routing/v1/ipns/:peerId', (req, res) => { callCount++ diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 763a186..d4c4e4a 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -123,16 +123,14 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } const res = await this.#makeRequest(url.toString(), getOptions) - if (res == null) { - throw new BadResponseError('No response received') + // Per IPIP-0513: Handle 404 as empty results (not an error) + // Old servers return 404, new servers return 200 with empty array + // Both should result in an empty iterator, not an error + if (res.status === 404) { + return // Return empty iterator } - if (!res.ok) { - if (res.status === 404) { - // https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes - // 404 (Not Found): must be returned if no matching records are found - throw new NotFoundError('No matching records found') - } + if (!res.ok) { if (res.status === 422) { // https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes // 422 (Unprocessable Entity): request does not conform to schema or semantic constraints @@ -150,10 +148,12 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV throw new BadResponseError('No Content-Type header received') } - if (contentType?.startsWith('application/json')) { + if (contentType.startsWith('application/json')) { const body = await res.json() + // Handle null/undefined Providers from servers (both old and new may return empty arrays) + const providers = body.Providers ?? [] - for (const provider of body.Providers) { + for (const provider of providers) { const record = this.#conformToPeerSchema(provider) if (record != null) { yield record @@ -200,10 +200,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } const res = await this.#makeRequest(url.toString(), getOptions) + // Per IPIP-0513: Handle 404 as empty results (not an error) + // Old servers return 404, new servers return 200 with empty array + // Both should result in an empty iterator, not an error if (res.status === 404) { - // https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes - // 404 (Not Found): must be returned if no matching records are found. - throw new NotFoundError('No matching records found') + return // Return empty iterator } if (res.status === 422) { @@ -217,10 +218,12 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV } const contentType = res.headers.get('Content-Type') - if (contentType === 'application/json') { + if (contentType?.startsWith('application/json')) { const body = await res.json() + // Handle null/undefined Peers from servers (both old and new may return empty arrays) + const peers = body.Peers ?? [] - for (const peer of body.Peers) { + for (const peer of peers) { const record = this.#conformToPeerSchema(peer) if (record != null) { yield record @@ -268,9 +271,10 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV log('getIPNS GET %s %d', resource, res.status) + // Per IPIP-0513: Handle 404 as "no record found" for backward compatibility + // IPNS is different - we still throw NotFoundError for 404 (backward compat) + // and also for 200 with non-IPNS content type (new behavior) if (res.status === 404) { - // https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes - // 404 (Not Found): must be returned if no matching records are found throw new NotFoundError('No matching records found') } @@ -280,6 +284,17 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV throw new InvalidRequestError('Request does not conform to schema or semantic constraints') } + if (!res.ok) { + throw new BadResponseError(`Unexpected status code: ${res.status}`) + } + + // Per IPIP-0513: Only Content-Type: application/vnd.ipfs.ipns-record indicates a valid record + // ANY other content type (or missing content-type) means no record found + const contentType = res.headers.get('Content-Type') + if (contentType == null || !contentType.includes('application/vnd.ipfs.ipns-record')) { + throw new NotFoundError('No matching records found') + } + if (res.body == null) { throw new BadResponseError('GET ipns response had no body') } diff --git a/packages/client/test/index.spec.ts b/packages/client/test/index.spec.ts index 27bd5ce..89d663f 100644 --- a/packages/client/test/index.spec.ts +++ b/packages/client/test/index.spec.ts @@ -2,7 +2,7 @@ import { generateKeyPair } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' -import { peerIdFromPrivateKey, peerIdFromString } from '@libp2p/peer-id' +import { peerIdFromPrivateKey, peerIdFromString, peerIdFromCID } from '@libp2p/peer-id' import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' import { createIPNSRecord, marshalIPNSRecord } from 'ipns' @@ -12,6 +12,23 @@ import { createDelegatedRoutingV1HttpApiClient } from '../src/index.js' import { itBrowser } from './fixtures/it.js' import type { DelegatedRoutingV1HttpApiClient } from '../src/index.js' +// Special test CIDs generated with: echo -n "" | ipfs add --cid-version 1 -n -Q +const TEST_CIDS = { + // Providers endpoint test CIDs + PROVIDERS_404: 'bafkreig3o4e7r4bpsc3hqirlzjeuie3w25tfjgmp6ufeaabwvuial3r4h4', // return404providers + PROVIDERS_NULL: 'bafkreicyicgkpqid2qs3kfc277f4tsx5tew3e63fgv7fn6t74sicjkv76i', // returnnullproviders + + // Peers endpoint test CIDs (libp2p-key format) + PEERS_404: 'k2k4r8pqu6ui9p0d0fewul7462tsb0pa57pi238gunrjxpfrg6zawrho', // return404peers + PEERS_NULL: 'k2k4r8nyb48mv6n6olsob1zsz77mhdrvwtjcryjil2qqqzye5jds4uur', // returnnullpeers + + // IPNS endpoint test CIDs (libp2p-key format) + IPNS_404: 'k2k4r8o3937xct4wma8gooitiip4mik0phkg8kt3b5x9y93a9dntvwjz', // return404ipns + IPNS_JSON: 'k2k4r8pajj9txni0h9nv9gxuj1mju4jmi94iq2r4jwhxk87hnuo94yom', // returnjsonipns + IPNS_HTML: 'k2k4r8kddkyieizgq7a32d9jc4nm99yupniet962vssrm34hamolquzk', // returnhtmlipns + IPNS_NO_CONTENT_TYPE: 'k2k4r8okqrya8gr449btdy5b6vw0q68dh7y3fps9qbi0zmcmybz7bjpu' // returnnocontentipns +} + if (process.env.ECHO_SERVER == null) { throw new Error('Echo server not configured correctly') } @@ -66,6 +83,66 @@ describe('delegated-routing-v1-http-api-client', () => { }))) }) + it('should return empty array when no providers found (200 with empty JSON array)', async () => { + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + + // Clear any providers + await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ Providers: [] }) + }) + + const provs = await all(client.getProviders(cid)) + expect(provs).to.be.empty() + }) + + it('should return empty array when no providers found (200 with empty NDJSON)', async () => { + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + + // Clear any providers - send empty NDJSON + await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { + method: 'POST', + headers: { + Accept: 'application/x-ndjson' + }, + body: '' // Empty NDJSON stream + }) + + const provs = await all(client.getProviders(cid)) + expect(provs).to.be.empty() + }) + + it('should return empty array when server returns 404 for providers (old server behavior)', async () => { + // Test backward compatibility with old servers that return 404 + const cid = CID.parse(TEST_CIDS.PROVIDERS_404) + + const provs = await all(client.getProviders(cid)) + expect(provs).to.be.empty() + }) + + it('should return empty array when server returns 404 for providers with NDJSON', async () => { + // Test backward compatibility with old servers that return 404 for NDJSON + const cid = CID.parse(TEST_CIDS.PROVIDERS_404) + + // Force NDJSON by using streaming + const provs = [] + for await (const provider of client.getProviders(cid)) { + provs.push(provider) + } + expect(provs).to.be.empty() + }) + + it('should handle null Providers field in JSON response', async () => { + // Some servers might return { Providers: null } instead of empty array + const cid = CID.parse(TEST_CIDS.PROVIDERS_NULL) + + const provs = await all(client.getProviders(cid)) + expect(provs).to.be.empty() + }) + it('should handle different Content-Type headers for JSON responses', async () => { const providers = [{ Protocol: 'transport-bitswap', @@ -204,6 +281,24 @@ describe('delegated-routing-v1-http-api-client', () => { expect(provs).to.be.empty() }) + it('should return empty array when server returns 404 for peers (old server behavior)', async () => { + // Test backward compatibility with old servers that return 404 + const peerCid = CID.parse(TEST_CIDS.PEERS_404) + const testPeerId = peerIdFromCID(peerCid) + + const peers = await all(client.getPeers(testPeerId)) + expect(peers).to.be.empty() + }) + + it('should handle null Peers field in JSON response', async () => { + // Some servers might return { Peers: null } instead of empty array + const peerCid = CID.parse(TEST_CIDS.PEERS_NULL) + const testPeerId = peerIdFromCID(peerCid) + + const peers = await all(client.getPeers(testPeerId)) + expect(peers).to.be.empty() + }) + it('should conform records to peer schema', async () => { const privateKey = await generateKeyPair('Ed25519') @@ -321,6 +416,42 @@ describe('delegated-routing-v1-http-api-client', () => { await expect(client.getIPNS(privateKey.publicKey.toCID())).to.be.rejected() }) + it('should throw NotFoundError when IPNS record not found', async () => { + const privateKey = await generateKeyPair('Ed25519') + + // Try to get a record that doesn't exist + // The mock server will return 200 with text/plain "Record not found" + await expect(client.getIPNS(privateKey.publicKey.toCID())).to.be.rejectedWith('No matching records found') + }) + + it('should throw NotFoundError when IPNS returns 404 (old server)', async () => { + // Test backward compatibility - old servers return 404 + const cid = CID.parse(TEST_CIDS.IPNS_404) as CID + + await expect(client.getIPNS(cid)).to.be.rejectedWith('No matching records found') + }) + + it('should throw NotFoundError when IPNS returns 200 with application/json', async () => { + // Per IPIP-0513: Only application/vnd.ipfs.ipns-record indicates a valid record + const cid = CID.parse(TEST_CIDS.IPNS_JSON) as CID + + await expect(client.getIPNS(cid)).to.be.rejectedWith('No matching records found') + }) + + it('should throw NotFoundError when IPNS returns 200 with text/html', async () => { + // Per IPIP-0513: Only application/vnd.ipfs.ipns-record indicates a valid record + const cid = CID.parse(TEST_CIDS.IPNS_HTML) as CID + + await expect(client.getIPNS(cid)).to.be.rejectedWith('No matching records found') + }) + + it('should throw NotFoundError when IPNS returns 200 with no content-type', async () => { + // Per IPIP-0513: Missing content-type means no record found + const cid = CID.parse(TEST_CIDS.IPNS_NO_CONTENT_TYPE) as CID + + await expect(client.getIPNS(cid)).to.be.rejectedWith('No matching records found') + }) + it('should put ipns', async () => { const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') const privateKey = await generateKeyPair('Ed25519') @@ -378,7 +509,6 @@ describe('delegated-routing-v1-http-api-client', () => { results.forEach(resultProviders => { expect(resultProviders.map(prov => ({ id: prov.ID.toString(), - // eslint-disable-next-line max-nested-callbacks addrs: prov.Addrs?.map(ma => ma.toString()) }))).to.deep.equal(providers.map(prov => ({ id: prov.ID, diff --git a/packages/client/test/routings.spec.ts b/packages/client/test/routings.spec.ts index 1cedfc6..053681d 100644 --- a/packages/client/test/routings.spec.ts +++ b/packages/client/test/routings.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-nested-callbacks */ /* eslint-env mocha */ import { generateKeyPair } from '@libp2p/crypto/keys' diff --git a/packages/interop/test/fixtures/create-helia.ts b/packages/interop/test/fixtures/create-helia.ts index 098f2f0..c7c0f53 100644 --- a/packages/interop/test/fixtures/create-helia.ts +++ b/packages/interop/test/fixtures/create-helia.ts @@ -4,11 +4,9 @@ import { ping } from '@libp2p/ping' import { createHelia as createNode } from 'helia' import { ipnsSelector } from 'ipns/selector' import { ipnsValidator } from 'ipns/validator' -import type { Libp2p } from '@libp2p/interface' -import type { KadDHT } from '@libp2p/kad-dht' import type { HeliaInit, HeliaLibp2p } from 'helia' -export async function createHelia (init?: Partial): Promise>> { +export async function createHelia (init?: Partial): Promise { const helia = await createNode({ libp2p: { peerDiscovery: [], @@ -30,6 +28,5 @@ export async function createHelia (init?: Partial): Promise { - let network: Array>> + let network: Array let server: FastifyInstance let client: DelegatedRoutingV1HttpApiClient diff --git a/packages/server/src/routes/routing/v1/errors.ts b/packages/server/src/routes/routing/v1/errors.ts new file mode 100644 index 0000000..abc94ef --- /dev/null +++ b/packages/server/src/routes/routing/v1/errors.ts @@ -0,0 +1,9 @@ +/** + * Check if an error indicates a "not found" condition + */ +export function isNotFoundError (err: any): boolean { + return err.code === 'ERR_NOT_FOUND' || + err.errors?.[0]?.code === 'ERR_NOT_FOUND' || + err.name === 'NotFoundError' || + err.errors?.[0]?.name === 'NotFoundError' +} diff --git a/packages/server/src/routes/routing/v1/ipns/get.ts b/packages/server/src/routes/routing/v1/ipns/get.ts index e909106..624fb5d 100644 --- a/packages/server/src/routes/routing/v1/ipns/get.ts +++ b/packages/server/src/routes/routing/v1/ipns/get.ts @@ -3,6 +3,7 @@ import { multihashToIPNSRoutingKey } from 'ipns' import { CID } from 'multiformats/cid' import { hasCode } from 'multiformats/hashes/digest' import { LIBP2P_KEY_CODEC } from '../../../../constants.js' +import { isNotFoundError } from '../errors.js' import type { Helia } from '@helia/interface' import type { FastifyInstance } from 'fastify' @@ -39,7 +40,7 @@ export default function getIpnsV1 (fastify: FastifyInstance, helia: Helia): void // PeerId must be encoded as a Libp2p-key CID. const { name: cidStr } = request.params cid = CID.parse(cidStr) - } catch (err) { + } catch (err: any) { fastify.log.error('could not parse CID from params', err) return reply.code(422).type('text/html').send('Unprocessable Entity') } @@ -63,10 +64,12 @@ export default function getIpnsV1 (fastify: FastifyInstance, helia: Helia): void .header('Content-Type', 'application/vnd.ipfs.ipns-record') .send(rawRecord) } catch (err: any) { - if (err.code === 'ERR_NOT_FOUND' || err.errors?.[0].code === 'ERR_NOT_FOUND' || - err.name === 'NotFoundError' || err.errors?.[0].name === 'NotFoundError' - ) { - return reply.code(404).send('Record not found') + if (isNotFoundError(err)) { + // Per IPIP-0513: Return 200 with text/plain to indicate no record found + return reply + .code(200) + .header('Content-Type', 'text/plain; charset=utf-8') + .send('Record not found') } throw err diff --git a/packages/server/src/routes/routing/v1/ipns/put.ts b/packages/server/src/routes/routing/v1/ipns/put.ts index 7f0ba13..6f0c2b8 100644 --- a/packages/server/src/routes/routing/v1/ipns/put.ts +++ b/packages/server/src/routes/routing/v1/ipns/put.ts @@ -47,7 +47,7 @@ export default function putIpnsV1 (fastify: FastifyInstance, helia: Helia): void // PeerId must be encoded as a Libp2p-key CID. const { name: cidStr } = request.params cid = CID.parse(cidStr) - } catch (err) { + } catch (err: any) { fastify.log.error('could not parse CID from params', err) return reply.code(422).type('text/html').send('Unprocessable Entity') } diff --git a/packages/server/src/routes/routing/v1/peers/get.ts b/packages/server/src/routes/routing/v1/peers/get.ts index 11ec7bb..eaa1e0a 100644 --- a/packages/server/src/routes/routing/v1/peers/get.ts +++ b/packages/server/src/routes/routing/v1/peers/get.ts @@ -2,6 +2,7 @@ import { PassThrough } from 'node:stream' import { setMaxListeners } from '@libp2p/interface' import { peerIdFromCID } from '@libp2p/peer-id' import { CID } from 'multiformats/cid' +import { isNotFoundError } from '../errors.js' import type { Helia } from '@helia/interface' import type { PeerId } from '@libp2p/interface' import type { FastifyInstance } from 'fastify' @@ -39,35 +40,55 @@ export default function getPeersV1 (fastify: FastifyInstance, helia: Helia): voi const { peerId: cidStr } = request.params const peerCid = CID.parse(cidStr) peerId = peerIdFromCID(peerCid) - } catch (err) { + } catch (err: any) { fastify.log.error('could not parse CID from params', err) return reply.code(422).type('text/html').send('Unprocessable Entity') } - const peerInfo = await helia.routing.findPeer(peerId, { - signal: controller.signal - }) - const peerRecord = { - Schema: 'peer', - ID: peerInfo.id.toString(), - Addrs: peerInfo.multiaddrs.map(ma => ma.toString()) - } + try { + const peerInfo = await helia.routing.findPeer(peerId, { + signal: controller.signal + }) + const peerRecord = { + Schema: 'peer', + ID: peerInfo.id.toString(), + Addrs: peerInfo.multiaddrs.map(ma => ma.toString()) + } - if (request.headers.accept?.includes('application/x-ndjson') === true) { - const stream = new PassThrough() - stream.push(JSON.stringify(peerRecord) + '\n') - stream.end() + if (request.headers.accept?.includes('application/x-ndjson') === true) { + const stream = new PassThrough() + stream.push(JSON.stringify(peerRecord) + '\n') + stream.end() - // these are .thenables but not .catchables? - return reply - .header('Content-Type', 'application/x-ndjson') - .send(stream) - } else { - return reply - .header('Content-Type', 'application/json') - .send({ - Peers: [peerRecord] - }) + // these are .thenables but not .catchables? + return await reply + .header('Content-Type', 'application/x-ndjson') + .send(stream) + } else { + return await reply + .header('Content-Type', 'application/json') + .send({ + Peers: [peerRecord] + }) + } + } catch (err: any) { + // Per IPIP-0513: Return 200 with empty results when peer is not found + if (isNotFoundError(err)) { + if (request.headers.accept?.includes('application/x-ndjson') === true) { + const stream = new PassThrough() + stream.end() // Empty NDJSON stream + return reply + .header('Content-Type', 'application/x-ndjson') + .send(stream) + } else { + return reply + .header('Content-Type', 'application/json') + .send({ + Peers: [] + }) + } + } + throw err } } }) diff --git a/packages/server/src/routes/routing/v1/providers/get.ts b/packages/server/src/routes/routing/v1/providers/get.ts index 7d2f369..736969a 100644 --- a/packages/server/src/routes/routing/v1/providers/get.ts +++ b/packages/server/src/routes/routing/v1/providers/get.ts @@ -51,7 +51,7 @@ export default function getProvidersV1 (fastify: FastifyInstance, helia: Helia): try { const { cid: cidStr } = request.params cid = CID.parse(cidStr) - } catch (err) { + } catch (err: any) { fastify.log.error('could not parse CID from params', err) return reply.code(422).type('text/html').send('Unprocessable Entity') } @@ -81,22 +81,22 @@ export default function getProvidersV1 (fastify: FastifyInstance, helia: Helia): .finally(() => { stream.end() }) - - return reply - .header('Content-Type', 'application/x-ndjson') - .send(stream) + } else { + // Per IPIP-0513: Return 200 with empty NDJSON stream for no results + stream.end() } + + return reply + .header('Content-Type', 'application/x-ndjson') + .send(stream) } else { const result = await nonStreamingHandler(cid, helia, { signal: controller.signal }) - if (result.Providers.length > 0) { - return reply.header('Content-Type', 'application/json').send(result) - } + // Per IPIP-0513: Always return 200 with results (which may be empty) + return reply.header('Content-Type', 'application/json').send(result) } - - reply.callNotFound() } }) } diff --git a/packages/server/test/index.spec.ts b/packages/server/test/index.spec.ts index 437df0e..8e45be3 100644 --- a/packages/server/test/index.spec.ts +++ b/packages/server/test/index.spec.ts @@ -66,17 +66,19 @@ describe('delegated-routing-v1-http-api-server', () => { expect(res.status).to.equal(404) }) - it('GET providers returns 404 if no providers are found', async () => { + it('GET providers returns 200 with empty array if no providers are found', async () => { helia.routing.findProviders = async function * () {} const res = await fetch(`${url}routing/v1/providers/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn`, { method: 'GET' }) - expect(res.status).to.equal(404) + expect(res.status).to.equal(200) + const json = await res.json() + expect(json).to.have.property('Providers').that.is.an('array').with.lengthOf(0) }) - it('GET providers returns 404 if no providers are found when streaming', async () => { + it('GET providers returns 200 with empty NDJSON if no providers are found when streaming', async () => { helia.routing.findProviders = async function * () {} const res = await fetch(`${url}routing/v1/providers/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn`, { @@ -86,7 +88,9 @@ describe('delegated-routing-v1-http-api-server', () => { } }) - expect(res.status).to.equal(404) + expect(res.status).to.equal(200) + const text = await res.text() + expect(text).to.equal('') }) it('GET providers returns providers', async () => { @@ -204,6 +208,47 @@ describe('delegated-routing-v1-http-api-server', () => { expect(json).to.have.deep.nested.property('Peers[0].Addrs', peer.multiaddrs.map(ma => ma.toString())) }) + it('GET peers returns 200 with empty array if peer is not found', async () => { + const peerId = peerIdFromPrivateKey(await generateKeyPair('Ed25519')) + + helia.routing.findPeer = async function () { + const error = new Error('Not found') + // @ts-expect-error - code property not on Error + error.code = 'ERR_NOT_FOUND' + throw error + } + + const res = await fetch(`${url}routing/v1/peers/${peerId.toCID().toString()}`, { + method: 'GET' + }) + expect(res.status).to.equal(200) + + const json = await res.json() + expect(json).to.have.property('Peers').that.is.an('array').with.lengthOf(0) + }) + + it('GET peers returns 200 with empty NDJSON if peer is not found when streaming', async () => { + const peerId = peerIdFromPrivateKey(await generateKeyPair('Ed25519')) + + helia.routing.findPeer = async function () { + const error = new Error('Not found') + // @ts-expect-error - code property not on Error + error.code = 'ERR_NOT_FOUND' + throw error + } + + const res = await fetch(`${url}routing/v1/peers/${peerId.toCID().toString()}`, { + method: 'GET', + headers: { + accept: 'application/x-ndjson' + } + }) + expect(res.status).to.equal(200) + + const text = await res.text() + expect(text).to.equal('') + }) + it('GET ipns returns 422 if peer id is not cid', async () => { const res = await fetch(`${url}routing/v1/ipns/${peerIdFromPrivateKey(await generateKeyPair('Ed25519')).toString()}`, { method: 'GET' @@ -242,6 +287,29 @@ describe('delegated-routing-v1-http-api-server', () => { expect(new Uint8Array(arrayBuffer)).to.equalBytes(marshalIPNSRecord(record)) }) + it('GET ipns returns 200 with text/plain if record is not found', async () => { + const peerId = peerIdFromPrivateKey(await generateKeyPair('Ed25519')) + + helia.routing.get = async function () { + const error = new Error('Not found') + // @ts-expect-error - code property not on Error + error.code = 'ERR_NOT_FOUND' + throw error + } + + const res = await fetch(`${url}routing/v1/ipns/${peerId.toCID().toString()}`, { + method: 'GET', + headers: { + accept: 'application/vnd.ipfs.ipns-record' + } + }) + + expect(res.status).to.equal(200) + expect(res.headers.get('content-type')).to.equal('text/plain; charset=utf-8') + const text = await res.text() + expect(text).to.equal('Record not found') + }) + it('PUT ipns puts record', async () => { const privateKey = await generateKeyPair('Ed25519') const peerId = peerIdFromPrivateKey(privateKey)