diff --git a/packages/client/.aegir.js b/packages/client/.aegir.js index 14e7890..a2b2183 100644 --- a/packages/client/.aegir.js +++ b/packages/client/.aegir.js @@ -1,96 +1,135 @@ -import EchoServer from 'aegir/echo-server' -import body from 'body-parser' - -/** @type {import('aegir').PartialOptions} */ -const options = { - test: { - before: async () => { - let callCount = 0 - let lastCalledUrl = '' - const providers = new Map() - const peers = new Map() - const ipnsGet = new Map() - const ipnsPut = new Map() - const echo = new EchoServer() - echo.polka.use(body.raw({ type: 'application/vnd.ipfs.ipns-record'})) - echo.polka.use(body.text()) - echo.polka.use((req, res, next) => { - next() - lastCalledUrl = req.url - }) - echo.polka.post('/add-providers/:cid', (req, res) => { - callCount++ - providers.set(req.params.cid, req.body) - res.end() - }) - echo.polka.get('/routing/v1/providers/:cid', (req, res) => { - callCount++ - const records = providers.get(req.params.cid) ?? '[]' - providers.delete(req.params.cid) - res.end(records) - }) - echo.polka.post('/add-peers/:peerId', (req, res) => { - callCount++ - peers.set(req.params.peerId, req.body) - res.end() - }) - 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) - }) - echo.polka.post('/add-ipns/:peerId', (req, res) => { - callCount++ - ipnsGet.set(req.params.peerId, req.body) - res.end() - }) - echo.polka.get('/routing/v1/ipns/:peerId', (req, res) => { - callCount++ - const record = ipnsGet.get(req.params.peerId) ?? '' - ipnsGet.delete(req.params.peerId) - - res.end(record) - }) - echo.polka.put('/routing/v1/ipns/:peerId', (req, res) => { - callCount++ - ipnsPut.set(req.params.peerId, req.body) - res.end() - }) - echo.polka.get('/get-ipns/:peerId', (req, res) => { - callCount++ - const record = ipnsPut.get(req.params.peerId) ?? '' - ipnsPut.delete(req.params.peerId) - - res.end(record) - }) - echo.polka.get('/get-call-count', (req, res) => { - res.end(callCount.toString()) - }) - echo.polka.get('/reset-call-count', (req, res) => { - callCount = 0 - res.end() - }) - echo.polka.get('/last-called-url', (req, res) => { - res.end(lastCalledUrl) - }) - - await echo.start() - - return { - env: { - ECHO_SERVER: `http://${echo.host}:${echo.port}` - }, - echo - } - }, - after: async (_, beforeResult) => { - if (beforeResult.echo != null) { - await beforeResult.echo.stop() - } - } - } -} - -export default options +import EchoServer from 'aegir/echo-server' +import body from 'body-parser' + +/** @type {import('aegir').PartialOptions} */ +const options = { + test: { + before: async () => { + let callCount = 0 + let lastCalledUrl = '' + const providers = new Map() + const peers = new Map() + const ipnsGet = new Map() + const ipnsPut = new Map() + const echo = new EchoServer() + echo.polka.use(body.raw({ type: 'application/vnd.ipfs.ipns-record'})) + echo.polka.use(body.text()) + echo.polka.use(body.json()) + echo.polka.use((req, res, next) => { + next() + lastCalledUrl = req.url + }) + echo.polka.post('/add-providers/:cid', (req, res) => { + callCount++ + try { + if (!req.headers['content-type']?.includes('application/json')) { + res.statusCode = 400 + res.end(JSON.stringify({ + error: 'Invalid content type. Expected application/json', + code: 'ERR_INVALID_INPUT' + })) + providers.delete(req.params.cid) + return + } + + const data = typeof req.body === 'string' + ? { Providers: req.body.split('\n').map(line => JSON.parse(line)) } + : req.body + + providers.set(req.params.cid, data) + res.end(JSON.stringify({ success: true })) + } catch (err) { + console.error('Error in add-providers:', err) + res.statusCode = 400 + res.end(JSON.stringify({ + error: err.message, + code: 'ERR_INVALID_INPUT' + })) + providers.delete(req.params.cid) + } + }) + echo.polka.get('/routing/v1/providers/:cid', (req, res) => { + callCount++ + try { + const providerData = providers.get(req.params.cid) || { Providers: [] } + const acceptHeader = req.headers.accept + + if (acceptHeader?.includes('application/x-ndjson')) { + res.setHeader('Content-Type', 'application/x-ndjson') + const providers = Array.isArray(providerData.Providers) ? providerData.Providers : [] + res.end(providers.map(p => JSON.stringify(p)).join('\n')) + } else { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(providerData)) + } + } catch (err) { + console.error('Error in get providers:', err) + res.statusCode = 500 + res.end(JSON.stringify({ error: err.message })) + } + }) + echo.polka.post('/add-peers/:peerId', (req, res) => { + callCount++ + peers.set(req.params.peerId, req.body) + res.end() + }) + 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) + }) + echo.polka.post('/add-ipns/:peerId', (req, res) => { + callCount++ + ipnsGet.set(req.params.peerId, req.body) + res.end() + }) + echo.polka.get('/routing/v1/ipns/:peerId', (req, res) => { + callCount++ + const record = ipnsGet.get(req.params.peerId) ?? '' + ipnsGet.delete(req.params.peerId) + + res.end(record) + }) + echo.polka.put('/routing/v1/ipns/:peerId', (req, res) => { + callCount++ + ipnsPut.set(req.params.peerId, req.body) + res.end() + }) + echo.polka.get('/get-ipns/:peerId', (req, res) => { + callCount++ + const record = ipnsPut.get(req.params.peerId) ?? '' + ipnsPut.delete(req.params.peerId) + + res.end(record) + }) + echo.polka.get('/get-call-count', (req, res) => { + res.end(callCount.toString()) + }) + echo.polka.get('/reset-call-count', (req, res) => { + callCount = 0 + res.end() + }) + echo.polka.get('/last-called-url', (req, res) => { + res.end(lastCalledUrl) + }) + + await echo.start() + + return { + env: { + ECHO_SERVER: `http://${echo.host}:${echo.port}` + }, + echo + } + }, + after: async (_, beforeResult) => { + if (beforeResult.echo != null) { + await beforeResult.echo.stop() + } + } + } +} + +export default options diff --git a/packages/client/package.json b/packages/client/package.json index 9198c2b..5c1b5b2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -142,6 +142,7 @@ "@libp2p/logger": "^5.0.1", "@libp2p/peer-id": "^5.0.1", "@multiformats/multiaddr": "^12.3.1", + "@playwright/test": "^1.50.1", "any-signal": "^4.1.1", "browser-readablestream-to-it": "^2.0.7", "ipns": "^10.0.0", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index bc44672..7e8f9b3 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -121,17 +121,22 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV this.#addFilterParams(url, options.filterAddrs, options.filterProtocols) const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } const res = await this.#makeRequest(url.toString(), getOptions) - - if (res.status === 404) { + if (res == null) { + throw new BadResponseError('No response received') + } + 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') - } + throw new NotFoundError('No matching records found') + } - if (res.status === 422) { + 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 - throw new InvalidRequestError('Request does not conform to schema or semantic constraints') + throw new InvalidRequestError('Request does not conform to schema or semantic constraints') + } + throw new BadResponseError(`Unexpected status code: ${res.status}`) } if (res.body == null) { @@ -139,7 +144,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV } const contentType = res.headers.get('Content-Type') - if (contentType === 'application/json') { + if (contentType == null) { + throw new BadResponseError('No Content-Type header received') + } + + if (contentType?.startsWith('application/json')) { const body = await res.json() for (const provider of body.Providers) { @@ -148,13 +157,15 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV yield record } } - } else { + } else if (contentType.includes('application/x-ndjson')) { for await (const provider of ndjson(toIt(res.body))) { const record = this.#conformToPeerSchema(provider) if (record != null) { yield record } } + } else { + throw new BadResponseError(`Unsupported Content-Type: ${contentType}`) } } catch (err) { log.error('getProviders errored:', err) @@ -387,7 +398,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV // Only try to use cache for GET requests if (requestMethod === 'GET') { const cachedResponse = await this.cache?.match(url) - if (cachedResponse != null) { + if (cachedResponse?.ok === true) { // Check if the cached response has expired const expires = parseInt(cachedResponse.headers.get('x-cache-expires') ?? '0', 10) if (expires > Date.now()) { diff --git a/packages/client/test/index.spec.ts b/packages/client/test/index.spec.ts index 1ffff14..3ee787e 100644 --- a/packages/client/test/index.spec.ts +++ b/packages/client/test/index.spec.ts @@ -52,9 +52,12 @@ describe('delegated-routing-v1-http-api-client', () => { // load providers for the router to fetch await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { method: 'POST', - body: providers.map(prov => JSON.stringify(prov)).join('\n') + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ Providers: providers }) }) - + await new Promise((resolve) => setTimeout(resolve, 100)) const provs = await all(client.getProviders(cid)) expect(provs.map(prov => ({ id: prov.ID.toString(), @@ -65,6 +68,73 @@ describe('delegated-routing-v1-http-api-client', () => { }))) }) + it('should handle different Content-Type headers for JSON responses', async () => { + const providers = [{ + Protocol: 'transport-bitswap', + Schema: 'bitswap', + Metadata: 'gBI=', + ID: (await generateKeyPair('Ed25519')).publicKey.toString(), + Addrs: ['/ip4/41.41.41.41/tcp/1234'] + }] + + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + + const contentTypes = [ + 'application/json', + 'application/json; charset=utf-8', + 'application/json;charset=UTF-8' + ] + + for (const contentType of contentTypes) { + // Add providers with proper payload structure + await fetch( + `${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, + { + method: 'POST', + headers: { + 'Content-Type': contentType + }, + body: JSON.stringify({ Providers: providers }) + } + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const provs = await all(client.getProviders(cid)) + + expect(provs).to.have.lengthOf( + 1, + `Failed for Content-Type: ${contentType}` + ) + + expect(provs[0].ID.toString()).to.equal(providers[0].ID) + expect(provs[0].Addrs[0].toString()).to.equal(providers[0].Addrs[0]) + } + }) + + it('should handle non-json input', async () => { + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + + const response = await fetch( + `${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + }, + body: 'not json' + } + ) + + expect(response.status).to.equal(400) + const errorData = await response.json() + expect(errorData).to.have.property('error') + expect(errorData).to.have.property('code', 'ERR_INVALID_INPUT') + + const provs = await all(client.getProviders(cid)) + expect(provs).to.be.empty() + }) + it('should add filter parameters the query of the request url', async () => { const providers = [{ Protocol: 'transport-bitswap', @@ -122,7 +192,7 @@ describe('delegated-routing-v1-http-api-client', () => { expect(searchParams.get('filter-addrs')).to.equal('tcp,!p2p-circuit') }) - it('should handle non-json input', async () => { + it('should handle non-json input without content-type header', async () => { const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') // load providers for the router to fetch @@ -317,7 +387,10 @@ describe('delegated-routing-v1-http-api-client', () => { // load providers for the router to fetch await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { method: 'POST', - body: providers.map(prov => JSON.stringify(prov)).join('\n') + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ Providers: providers }) }) // Reset call count before our test @@ -370,7 +443,10 @@ describe('delegated-routing-v1-http-api-client', () => { // load providers for the router to fetch await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { method: 'POST', - body: providers.map(prov => JSON.stringify(prov)).join('\n') + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ Providers: providers }) }) // Reset call count diff --git a/packages/client/test/routings.spec.ts b/packages/client/test/routings.spec.ts index 9dac3b6..3f56fb0 100644 --- a/packages/client/test/routings.spec.ts +++ b/packages/client/test/routings.spec.ts @@ -2,15 +2,27 @@ /* eslint-env mocha */ import { generateKeyPair } from '@libp2p/crypto/keys' -import { contentRoutingSymbol, peerRoutingSymbol, start, stop } from '@libp2p/interface' +import { + contentRoutingSymbol, + peerRoutingSymbol, + start, + stop +} from '@libp2p/interface' import { peerIdFromPrivateKey } from '@libp2p/peer-id' import { expect } from 'aegir/chai' -import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey } from 'ipns' +import { + createIPNSRecord, + marshalIPNSRecord, + multihashToIPNSRoutingKey +} from 'ipns' import all from 'it-all' import { CID } from 'multiformats/cid' import { concat as uint8ArrayConcat } from 'uint8arrays/concat' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' -import { createDelegatedRoutingV1HttpApiClient, type DelegatedRoutingV1HttpApiClient } from '../src/index.js' +import { + createDelegatedRoutingV1HttpApiClient, + type DelegatedRoutingV1HttpApiClient +} from '../src/index.js' import type { PeerRouting, ContentRouting } from '@libp2p/interface' const serverUrl = process.env.ECHO_SERVER @@ -23,7 +35,9 @@ describe('libp2p content-routing', () => { let client: DelegatedRoutingV1HttpApiClient beforeEach(async () => { - client = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl), { cacheTTL: 0 }) + client = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl), { + cacheTTL: 0 + }) await start(client) }) @@ -66,20 +80,27 @@ describe('libp2p content-routing', () => { const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') - // load providers for the router to fetch + // load providers for the router to fetch with proper JSON formatting await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { method: 'POST', - body: providers.map(prov => JSON.stringify(prov)).join('\n') + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ Providers: providers }) }) - const provs = await all(routing.findProviders(cid)) - expect(provs.map(prov => ({ - id: prov.id.toString(), - multiaddrs: prov.multiaddrs.map(ma => ma.toString()) - }))).to.deep.equal(providers.map(prov => ({ - id: prov.ID, - multiaddrs: prov.Addrs - }))) + const foundProviders = await all(routing.findProviders(cid)) + expect( + foundProviders.map((p) => ({ + id: p.id.toString(), + multiaddrs: p.multiaddrs.map((ma) => ma.toString()) + })) + ).to.deep.equal( + providers.map((prov) => ({ + id: prov.ID, + multiaddrs: prov.Addrs + })) + ) }) it('should provide without error', async () => { @@ -109,12 +130,15 @@ describe('libp2p content-routing', () => { await routing.put(key, marshalIPNSRecord(record)) // load record that our client just PUT to remote server - const res = await fetch(`${process.env.ECHO_SERVER}/get-ipns/${privateKey.publicKey.toCID()}`, { - method: 'GET', - headers: { - Accept: 'application/vnd.ipfs.ipns-record' + const res = await fetch( + `${process.env.ECHO_SERVER}/get-ipns/${privateKey.publicKey.toCID()}`, + { + method: 'GET', + headers: { + Accept: 'application/vnd.ipfs.ipns-record' + } } - }) + ) const receivedRecord = new Uint8Array(await res.arrayBuffer()) expect(marshalIPNSRecord(record)).to.equalBytes(receivedRecord) @@ -150,13 +174,16 @@ describe('libp2p content-routing', () => { const record = await createIPNSRecord(privateKey, cid, 0, 1000) // load record for the router to fetch - await fetch(`${process.env.ECHO_SERVER}/add-ipns/${privateKey.publicKey.toCID()}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/vnd.ipfs.ipns-record' - }, - body: marshalIPNSRecord(record) - }) + await fetch( + `${process.env.ECHO_SERVER}/add-ipns/${privateKey.publicKey.toCID()}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.ipfs.ipns-record' + }, + body: marshalIPNSRecord(record) + } + ) const key = uint8ArrayConcat([ uint8ArrayFromString('/ipns/'), @@ -181,8 +208,10 @@ describe('libp2p content-routing', () => { privateKey.publicKey.toMultihash().bytes ]) - await expect(routing.get(key)).to.eventually.be.rejected - .with.property('name', 'NotFoundError') + await expect(routing.get(key)).to.eventually.be.rejected.with.property( + 'name', + 'NotFoundError' + ) await expect(getServerCallCount()).to.eventually.equal(0) }) @@ -226,15 +255,20 @@ describe('libp2p peer-routing', () => { }] // load peer for the router to fetch - await fetch(`${process.env.ECHO_SERVER}/add-peers/${peerId.toCID().toString()}`, { - method: 'POST', - body: records.map(prov => JSON.stringify(prov)).join('\n') - }) + await fetch( + `${process.env.ECHO_SERVER}/add-peers/${peerId.toCID().toString()}`, + { + method: 'POST', + body: records.map((prov) => JSON.stringify(prov)).join('\n') + } + ) const peerInfo = await routing.findPeer(peerId) expect(peerInfo.id.toString()).to.equal(records[0].ID) - expect(peerInfo.multiaddrs.map(ma => ma.toString())).to.deep.equal(records[0].Addrs) + expect(peerInfo.multiaddrs.map((ma) => ma.toString())).to.deep.equal( + records[0].Addrs + ) }) it('should not get closest peers', async () => { @@ -244,7 +278,9 @@ describe('libp2p peer-routing', () => { throw new Error('PeerRouting not found') } - await expect(all(routing.getClosestPeers(Uint8Array.from([0, 1, 2, 3, 4])))).to.eventually.be.empty() + await expect( + all(routing.getClosestPeers(Uint8Array.from([0, 1, 2, 3, 4]))) + ).to.eventually.be.empty() }) }) })