diff --git a/README.md b/README.md index fab1e7c..695666c 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@

-# helia-routing-v1-http-api +# helia-delegated-routing-v1-http-api [![ipfs.tech](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](https://ipfs.tech) [![Discuss](https://img.shields.io/discourse/https/discuss.ipfs.tech/posts.svg?style=flat-square)](https://discuss.ipfs.tech) -[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-routing-v1-http-api) -[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) +[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-delegated-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-delegated-routing-v1-http-api) +[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-delegated-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-delegated-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) -> The Routing V1 HTTP API powered by Helia +> The Delegated Routing V1 HTTP API powered by Helia + +This repo contains a server implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/) along with a client that can be used to interact with any compliant server implementation. ## Table of contents @@ -22,13 +24,13 @@ ## Structure -- [`/packages/client`](./packages/client) A Routing V1 HTTP API client -- [`/packages/interop`](./packages/interop) Interop tests for the Routing V1 HTTP API server powered by Helia -- [`/packages/server`](./packages/server) A Routing V1 HTTP API server powered by Helia +- [`/packages/client`](./packages/client) A Delegated Routing V1 HTTP API client +- [`/packages/interop`](./packages/interop) Interop tests for the Delegated Routing V1 HTTP API server powered by Helia +- [`/packages/server`](./packages/server) A Delegated Routing V1 HTTP API server powered by Helia ## API Docs -- +- ## License @@ -39,7 +41,7 @@ Licensed under either of ## Contribute -Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-routing-v1-http-api/issues). +Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues). Also see our [contributing document](https://github.com/ipfs/community/blob/master/CONTRIBUTING_JS.md) for more information on how we work, and about contributing in general. diff --git a/package.json b/package.json index 2d71109..f10fe7d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { - "name": "helia-routing-v1-http-api", + "name": "helia-delegated-routing-v1-http-api", "version": "1.0.0", - "description": "The Routing V1 HTTP API powered by Helia", + "description": "The Delegated Routing V1 HTTP API powered by Helia", "license": "Apache-2.0 OR MIT", - "homepage": "https://github.com/ipfs/helia-routing-v1-http-api#readme", + "homepage": "https://github.com/ipfs/helia-delegated-routing-v1-http-api#readme", "repository": { "type": "git", - "url": "git+https://github.com/ipfs/helia-routing-v1-http-api.git" + "url": "git+https://github.com/ipfs/helia-delegated-routing-v1-http-api.git" }, "bugs": { - "url": "https://github.com/ipfs/helia-routing-v1-http-api/issues" + "url": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues" }, "keywords": [ "ipfs" diff --git a/packages/client/.aegir.js b/packages/client/.aegir.js index 411adee..739d8ce 100644 --- a/packages/client/.aegir.js +++ b/packages/client/.aegir.js @@ -6,17 +6,51 @@ const options = { test: { before: async () => { 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.post('/add-providers/:cid', (req, res) => { providers.set(req.params.cid, req.body) res.end() }) echo.polka.get('/routing/v1/providers/:cid', (req, res) => { - const provs = providers.get(req.params.cid) ?? '[]' + const records = providers.get(req.params.cid) ?? '[]' providers.delete(req.params.cid) - res.end(provs) + res.end(records) + }) + echo.polka.post('/add-peers/:peerId', (req, res) => { + peers.set(req.params.peerId, req.body) + res.end() + }) + echo.polka.get('/routing/v1/peers/:peerId', (req, res) => { + const records = peers.get(req.params.peerId) ?? '[]' + peers.delete(req.params.peerId) + + res.end(records) + }) + echo.polka.post('/add-ipns/:peerId', (req, res) => { + ipnsGet.set(req.params.peerId, req.body) + res.end() + }) + echo.polka.get('/routing/v1/ipns/:peerId', (req, res) => { + const record = ipnsGet.get(req.params.peerId) ?? '' + ipnsGet.delete(req.params.peerId) + + res.end(record) + }) + echo.polka.put('/routing/v1/ipns/:peerId', (req, res) => { + ipnsPut.set(req.params.peerId, req.body) + res.end() + }) + echo.polka.get('/get-ipns/:peerId', (req, res) => { + const record = ipnsPut.get(req.params.peerId) ?? '' + ipnsPut.delete(req.params.peerId) + + res.end(record) }) await echo.start() diff --git a/packages/client/README.md b/packages/client/README.md index 877429b..aa27f43 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -4,14 +4,16 @@

-# @helia/routing-v1-http-api-client +# @helia/delegated-routing-v1-http-api-client [![ipfs.tech](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](https://ipfs.tech) [![Discuss](https://img.shields.io/discourse/https/discuss.ipfs.tech/posts.svg?style=flat-square)](https://discuss.ipfs.tech) -[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-routing-v1-http-api) -[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) +[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-delegated-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-delegated-routing-v1-http-api) +[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-delegated-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-delegated-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) -> A Routing V1 HTTP API client +> A Delegated Routing V1 HTTP API client + +A client implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/) that can be used to interact with any compliant server implementation. ## Table of contents @@ -23,12 +25,12 @@ ## Install ```console -$ npm i @helia/routing-v1-http-api-client +$ npm i @helia/delegated-routing-v1-http-api-client ``` ## API Docs -- +- ## License @@ -39,7 +41,7 @@ Licensed under either of ## Contribute -Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-routing-v1-http-api/issues). +Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues). Also see our [contributing document](https://github.com/ipfs/community/blob/master/CONTRIBUTING_JS.md) for more information on how we work, and about contributing in general. diff --git a/packages/client/package.json b/packages/client/package.json index 697c470..21972f4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,15 +1,15 @@ { - "name": "@helia/routing-v1-http-api-client", - "version": "1.0.2", - "description": "A Routing V1 HTTP API client", + "name": "@helia/delegated-routing-v1-http-api-client", + "version": "0.0.0", + "description": "A Delegated Routing V1 HTTP API client", "license": "Apache-2.0 OR MIT", - "homepage": "https://github.com/ipfs/helia-routing-v1-http-api/tree/master/packages/client#readme", + "homepage": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/tree/master/packages/client#readme", "repository": { "type": "git", - "url": "git+https://github.com/ipfs/helia-routing-v1-http-api.git" + "url": "git+https://github.com/ipfs/helia-delegated-routing-v1-http-api.git" }, "bugs": { - "url": "https://github.com/ipfs/helia-routing-v1-http-api/issues" + "url": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues" }, "keywords": [ "IPFS" @@ -132,10 +132,11 @@ "dependencies": { "@libp2p/interface": "^0.1.2", "@libp2p/logger": "^3.0.2", - "@libp2p/peer-id": "^3.0.2", + "@libp2p/peer-id": "^3.0.3", "@multiformats/multiaddr": "^12.1.3", "any-signal": "^4.1.1", "browser-readablestream-to-it": "^2.0.3", + "ipns": "^7.0.1", "it-all": "^3.0.2", "iterable-ndjson": "^1.1.0", "multiformats": "^12.1.1", @@ -143,7 +144,7 @@ "p-queue": "^7.3.4" }, "devDependencies": { - "@libp2p/peer-id-factory": "^3.0.3", + "@libp2p/peer-id-factory": "^3.0.5", "aegir": "^41.0.0", "body-parser": "^1.20.2" } diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 6a3c7a9..0fcac0c 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -4,31 +4,25 @@ import { peerIdFromString } from '@libp2p/peer-id' import { multiaddr } from '@multiformats/multiaddr' import { anySignal } from 'any-signal' import toIt from 'browser-readablestream-to-it' +import { unmarshal, type IPNSRecord, marshal, peerIdToRoutingKey } from 'ipns' +import { ipnsValidator } from 'ipns/validator' // @ts-expect-error no types import ndjson from 'iterable-ndjson' import defer from 'p-defer' import PQueue from 'p-queue' -import type { RoutingV1HttpApiClient, RoutingV1HttpApiClientInit } from './index.js' +import type { DelegatedRoutingV1HttpApiClient, DelegatedRoutingV1HttpApiClientInit, PeerRecord } from './index.js' import type { AbortOptions } from '@libp2p/interface' -import type { PeerInfo } from '@libp2p/interface/peer-info' -import type { Multiaddr } from '@multiformats/multiaddr' +import type { PeerId } from '@libp2p/interface/peer-id' import type { CID } from 'multiformats' -const log = logger('routing-v1-http-api-client') - -interface RoutingV1HttpApiGetProvidersResponse { - Protocol: string - Schema: string - ID: string - Addrs: Multiaddr[] -} +const log = logger('delegated-routing-v1-http-api-client') const defaultValues = { concurrentRequests: 4, timeout: 30e3 } -export class DefaultRoutingV1HttpApiClient implements RoutingV1HttpApiClient { +export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpApiClient { private started: boolean private readonly httpQueue: PQueue private readonly shutDownController: AbortController @@ -38,7 +32,7 @@ export class DefaultRoutingV1HttpApiClient implements RoutingV1HttpApiClient { /** * Create a new DelegatedContentRouting instance */ - constructor (url: string | URL, init: RoutingV1HttpApiClientInit = {}) { + constructor (url: string | URL, init: DelegatedRoutingV1HttpApiClientInit = {}) { this.started = false this.shutDownController = new AbortController() this.httpQueue = new PQueue({ @@ -62,8 +56,8 @@ export class DefaultRoutingV1HttpApiClient implements RoutingV1HttpApiClient { this.started = false } - async * getProviders (cid: CID, options: AbortOptions | undefined = {}): AsyncGenerator { - log('findProviders starts: %c', cid) + async * getProviders (cid: CID, options: AbortOptions = {}): AsyncGenerator { + log('getProviders starts: %c', cid) const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)]) const onStart = defer() @@ -77,46 +71,196 @@ export class DefaultRoutingV1HttpApiClient implements RoutingV1HttpApiClient { try { await onStart.promise - // https://github.com/ipfs/specs/blob/main/routing/ROUTING_V1_HTTP.md#api + // https://specs.ipfs.tech/routing/http-routing-v1/ const resource = `${this.clientUrl}routing/v1/providers/${cid.toString()}` const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } - const a = await fetch(resource, getOptions) + const res = await fetch(resource, getOptions) - if (a.body == null) { + if (res.body == null) { throw new CodeError('Routing response had no body', 'ERR_BAD_RESPONSE') } - for await (const event of ndjson(toIt(a.body))) { - if (event.Protocol !== 'transport-bitswap') { - continue + const contentType = res.headers.get('Content-Type') + if (contentType === 'application/json') { + const body = await res.json() + + for (const provider of body.Providers) { + const record = this.#handleProviderRecords(provider) + if (record != null) { + yield record + } } + } else { + for await (const provider of ndjson(toIt(res.body))) { + const record = this.#handleProviderRecords(provider) + if (record != null) { + yield record + } + } + } + } catch (err) { + log.error('getProviders errored:', err) + } finally { + signal.clear() + onFinish.resolve() + log('getProviders finished: %c', cid) + } + } + + async * getPeerInfo (peerId: PeerId, options: AbortOptions | undefined = {}): AsyncGenerator { + log('getPeers starts: %c', peerId) + + const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)]) + const onStart = defer() + const onFinish = defer() + + void this.httpQueue.add(async () => { + onStart.resolve() + return onFinish.promise + }) - yield this.#mapProvider(event) + try { + await onStart.promise + + // https://specs.ipfs.tech/routing/http-routing-v1/ + const resource = `${this.clientUrl}routing/v1/peers/${peerId.toCID().toString()}` + const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } + const res = await fetch(resource, getOptions) + + if (res.body == null) { + throw new CodeError('Routing response had no body', 'ERR_BAD_RESPONSE') + } + + const contentType = res.headers.get('Content-Type') + if (contentType === 'application/json') { + const body = await res.json() + + for (const peer of body.Peers) { + const record = this.#handlePeerRecords(peerId, peer) + if (record != null) { + yield record + } + } + } else { + for await (const peer of ndjson(toIt(res.body))) { + const record = this.#handlePeerRecords(peerId, peer) + if (record != null) { + yield record + } + } } } catch (err) { - log.error('findProviders errored:', err) + log.error('getPeers errored:', err) } finally { signal.clear() onFinish.resolve() - log('findProviders finished: %c', cid) + log('getPeers finished: %c', peerId) } } - #mapProvider (event: RoutingV1HttpApiGetProvidersResponse): PeerInfo { - const peer = peerIdFromString(event.ID) - const ma: Multiaddr[] = [] + async getIPNS (peerId: PeerId, options: AbortOptions = {}): Promise { + log('getIPNS starts: %c', peerId) + + const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)]) + const onStart = defer() + const onFinish = defer() + + void this.httpQueue.add(async () => { + onStart.resolve() + return onFinish.promise + }) - for (const strAddr of event.Addrs) { - const addr = multiaddr(strAddr) - ma.push(addr) + try { + await onStart.promise + + // https://specs.ipfs.tech/routing/http-routing-v1/ + const resource = `${this.clientUrl}routing/v1/ipns/${peerId.toCID().toString()}` + const getOptions = { headers: { Accept: 'application/vnd.ipfs.ipns-record' }, signal } + const res = await fetch(resource, getOptions) + + if (res.body == null) { + throw new CodeError('GET ipns response had no body', 'ERR_BAD_RESPONSE') + } + + const body = new Uint8Array(await res.arrayBuffer()) + await ipnsValidator(peerIdToRoutingKey(peerId), body) + return unmarshal(body) + } finally { + signal.clear() + onFinish.resolve() + log('getIPNS finished: %c', peerId) + } + } + + async putIPNS (peerId: PeerId, record: IPNSRecord, options: AbortOptions = {}): Promise { + log('getIPNS starts: %c', peerId) + + const signal = anySignal([this.shutDownController.signal, options.signal, AbortSignal.timeout(this.timeout)]) + const onStart = defer() + const onFinish = defer() + + void this.httpQueue.add(async () => { + onStart.resolve() + return onFinish.promise + }) + + try { + await onStart.promise + + const body = marshal(record) + + // https://specs.ipfs.tech/routing/http-routing-v1/ + const resource = `${this.clientUrl}routing/v1/ipns/${peerId.toCID().toString()}` + const getOptions = { method: 'PUT', headers: { 'Content-Type': 'application/vnd.ipfs.ipns-record' }, body, signal } + const res = await fetch(resource, getOptions) + if (res.status !== 200) { + throw new CodeError('PUT ipns response had status other than 200', 'ERR_BAD_RESPONSE') + } + } finally { + signal.clear() + onFinish.resolve() + log('getIPNS finished: %c', peerId) + } + } + + #handleProviderRecords (record: any): PeerRecord | undefined { + if (record.Schema === 'peer') { + // Peer schema can have additional, user-defined, fields. + record.ID = peerIdFromString(record.ID) + record.Addrs = record.Addrs.map(multiaddr) + return record + } + + if (record.Schema === 'bitswap') { + // Bitswap schema is deprecated, was incorrectly used when server had no + // information about actual protocols, so we convert it to peer result + // without protocol information + return { + Schema: 'peer', + ID: peerIdFromString(record.ID), + Addrs: record.Addrs.map(multiaddr), + Protocols: record.Protocol != null ? [record.Protocol] : [] + } } - const pi = { - id: peer, - multiaddrs: ma, - protocols: [] + if (record.ID != null && Array.isArray(record.Addrs)) { + return { + Schema: 'peer', + ID: peerIdFromString(record.ID), + Addrs: record.Addrs.map(multiaddr), + Protocols: Array.isArray(record.Protocols) ? record.Protocols : [] + } } + } - return pi + #handlePeerRecords (peerId: PeerId, record: any): PeerRecord | undefined { + if (record.Schema === 'peer') { + // Peer schema can have additional, user-defined, fields. + record.ID = peerIdFromString(record.ID) + record.Addrs = record.Addrs.map(multiaddr) + if (peerId.equals(record.ID)) { + return record + } + } } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 480538c..7f96905 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,12 +17,21 @@ * ``` */ -import { DefaultRoutingV1HttpApiClient } from './client.js' +import { DefaultDelegatedRoutingV1HttpApiClient } from './client.js' import type { AbortOptions } from '@libp2p/interface' -import type { PeerInfo } from '@libp2p/interface/peer-info' +import type { PeerId } from '@libp2p/interface/peer-id' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { IPNSRecord } from 'ipns' import type { CID } from 'multiformats/cid' -export interface RoutingV1HttpApiClientInit { +export interface PeerRecord { + Schema: 'peer' + ID: PeerId + Addrs?: Multiaddr[] + Protocols?: string[] +} + +export interface DelegatedRoutingV1HttpApiClientInit { /** * A concurrency limit to avoid request flood in web browser (default: 4) * @@ -36,12 +45,27 @@ export interface RoutingV1HttpApiClientInit { timeout?: number } -export interface RoutingV1HttpApiClient { +export interface DelegatedRoutingV1HttpApiClient { /** * Returns an async generator of PeerInfos that can provide the content * for the passed CID */ - getProviders(cid: CID, options?: AbortOptions): AsyncGenerator + getProviders(cid: CID, options?: AbortOptions): AsyncGenerator + + /** + * Returns an async generator of PeerInfos for the provided PeerId + */ + getPeerInfo(peerId: PeerId, options?: AbortOptions): AsyncGenerator + + /** + * Returns a promise of a IPNSRecord for the given PeerId + */ + getIPNS(peerId: PeerId, options?: AbortOptions): Promise + + /** + * Publishes the given IPNSRecord for the provided PeerId + */ + putIPNS(peerId: PeerId, record: IPNSRecord, options?: AbortOptions): Promise /** * Shut down any currently running HTTP requests and clear up any resources @@ -53,6 +77,6 @@ export interface RoutingV1HttpApiClient { /** * Create and return a client to use with a Routing V1 HTTP API server */ -export function createRoutingV1HttpApiClient (url: URL, init: RoutingV1HttpApiClientInit = {}): RoutingV1HttpApiClient { - return new DefaultRoutingV1HttpApiClient(url, init) +export function createDelegatedRoutingV1HttpApiClient (url: URL, init: DelegatedRoutingV1HttpApiClientInit = {}): DelegatedRoutingV1HttpApiClient { + return new DefaultDelegatedRoutingV1HttpApiClient(url, init) } diff --git a/packages/client/test/index.spec.ts b/packages/client/test/index.spec.ts index c106a36..c4e4774 100644 --- a/packages/client/test/index.spec.ts +++ b/packages/client/test/index.spec.ts @@ -2,9 +2,10 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' +import { create as createIpnsRecord, marshal as marshalIpnsRecord } from 'ipns' import all from 'it-all' import { CID } from 'multiformats/cid' -import { createRoutingV1HttpApiClient, type RoutingV1HttpApiClient } from '../src/index.js' +import { createDelegatedRoutingV1HttpApiClient, type DelegatedRoutingV1HttpApiClient } from '../src/index.js' if (process.env.ECHO_SERVER == null) { throw new Error('Echo server not configured correctly') @@ -12,11 +13,11 @@ if (process.env.ECHO_SERVER == null) { const serverUrl = process.env.ECHO_SERVER -describe('routing-v1-http-api-client', () => { - let client: RoutingV1HttpApiClient +describe('delegated-routing-v1-http-api-client', () => { + let client: DelegatedRoutingV1HttpApiClient beforeEach(() => { - client = createRoutingV1HttpApiClient(new URL(serverUrl)) + client = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl)) }) afterEach(async () => { @@ -33,11 +34,14 @@ describe('routing-v1-http-api-client', () => { ID: (await createEd25519PeerId()).toString(), Addrs: ['/ip4/41.41.41.41/tcp/1234'] }, { - Protocol: 'transport-bitswap', - Schema: 'bitswap', + Protocols: ['transport-bitswap'], + Schema: 'peer', Metadata: 'gBI=', ID: (await createEd25519PeerId()).toString(), Addrs: ['/ip4/42.42.42.42/tcp/1234'] + }, { + ID: (await createEd25519PeerId()).toString(), + Addrs: ['/ip4/43.43.43.43/tcp/1234'] }] const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') @@ -50,8 +54,8 @@ describe('routing-v1-http-api-client', () => { const provs = await all(client.getProviders(cid)) expect(provs.map(prov => ({ - id: prov.id.toString(), - addrs: prov.multiaddrs.map(ma => ma.toString()) + id: prov.ID.toString(), + addrs: prov.Addrs?.map(ma => ma.toString()) }))).to.deep.equal(providers.map(prov => ({ id: prov.ID, addrs: prov.Addrs @@ -103,4 +107,103 @@ describe('routing-v1-http-api-client', () => { const provs = await all(client.getProviders(cid)) expect(provs).to.be.empty() }) + + it('should find peers and only accepts correct peer records', async () => { + const peerId = await createEd25519PeerId() + + const records = [{ + Protocol: 'transport-bitswap', + Schema: 'bitswap', + Metadata: 'gBI=', + ID: peerId.toString(), + Addrs: ['/ip4/41.41.41.41/tcp/1234'] + }, { + Protocol: 'transport-saddle', + Schema: 'horse-ride', + Metadata: 'gBI=', + ID: peerId.toString(), + Addrs: ['/ip4/41.41.41.41/tcp/1234'] + }, { + Protocols: ['transport-bitswap'], + Schema: 'peer', + Metadata: 'gBI=', + ID: peerId.toString(), + Addrs: ['/ip4/42.42.42.42/tcp/1234'] + }, { + Protocols: ['transport-bitswap'], + Schema: 'peer', + Metadata: 'gBI=', + ID: (await createEd25519PeerId()).toString(), + Addrs: ['/ip4/42.42.42.42/tcp/1234'] + }] + + // 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') + }) + + const peerRecords = await all(client.getPeerInfo(peerId)) + expect(peerRecords.map(peerRecord => ({ + ...peerRecord, + ID: peerRecord.ID.toString(), + Addrs: peerRecord.Addrs?.map(ma => ma.toString()) + }))).to.deep.equal([ + records[2] + ]) + }) + + it('should get ipns record', async () => { + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + const peerId = await createEd25519PeerId() + const record = await createIpnsRecord(peerId, cid, 0, 1000) + + // load record for the router to fetch + await fetch(`${process.env.ECHO_SERVER}/add-ipns/${peerId.toCID().toString()}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.ipfs.ipns-record' + }, + body: marshalIpnsRecord(record) + }) + + const ipnsRecord = await client.getIPNS(peerId) + expect(marshalIpnsRecord(ipnsRecord)).to.equalBytes(marshalIpnsRecord(record)) + }) + + it('get ipns record fails with bad record', async () => { + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + const peerId = await createEd25519PeerId() + const record = await createIpnsRecord(await createEd25519PeerId(), cid, 0, 1000) + + // load record for the router to fetch + await fetch(`${process.env.ECHO_SERVER}/add-ipns/${peerId.toCID().toString()}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/vnd.ipfs.ipns-record' + }, + body: marshalIpnsRecord(record) + }) + + await expect(client.getIPNS(peerId)).to.be.rejected() + }) + + it('should put ipns', async () => { + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + const peerId = await createEd25519PeerId() + const record = await createIpnsRecord(peerId, cid, 0, 1000) + + await client.putIPNS(peerId, record) + + // load record that our client just PUT to remote server + const res = await fetch(`${process.env.ECHO_SERVER}/get-ipns/${peerId.toCID().toString()}`, { + method: 'GET', + headers: { + Accept: 'application/vnd.ipfs.ipns-record' + } + }) + + const receivedRecord = new Uint8Array(await res.arrayBuffer()) + expect(marshalIpnsRecord(record)).to.equalBytes(receivedRecord) + }) }) diff --git a/packages/interop/README.md b/packages/interop/README.md index 4419c69..22ff88f 100644 --- a/packages/interop/README.md +++ b/packages/interop/README.md @@ -4,14 +4,14 @@

-# @helia/routing-v1-http-api-interop +# @helia/delegated-routing-v1-http-api-interop [![ipfs.tech](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](https://ipfs.tech) [![Discuss](https://img.shields.io/discourse/https/discuss.ipfs.tech/posts.svg?style=flat-square)](https://discuss.ipfs.tech) -[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-routing-v1-http-api) -[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) +[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-delegated-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-delegated-routing-v1-http-api) +[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-delegated-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-delegated-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) -> Interop tests for the Routing V1 HTTP API server powered by Helia +> Interop tests for the Delegated Routing V1 HTTP API server powered by Helia ## Table of contents @@ -22,7 +22,7 @@ ## Install ```console -$ npm i @helia/routing-v1-http-api-interop +$ npm i @helia/delegated-routing-v1-http-api-interop ``` ## License @@ -34,7 +34,7 @@ Licensed under either of ## Contribute -Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-routing-v1-http-api/issues). +Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues). Also see our [contributing document](https://github.com/ipfs/community/blob/master/CONTRIBUTING_JS.md) for more information on how we work, and about contributing in general. diff --git a/packages/interop/package.json b/packages/interop/package.json index 7b07fd4..81ae4fb 100644 --- a/packages/interop/package.json +++ b/packages/interop/package.json @@ -1,15 +1,15 @@ { - "name": "@helia/routing-v1-http-api-interop", - "version": "1.0.0", - "description": "Interop tests for the Routing V1 HTTP API server powered by Helia", + "name": "@helia/delegated-routing-v1-http-api-interop", + "version": "0.0.0", + "description": "Interop tests for the Delegated Routing V1 HTTP API server powered by Helia", "license": "Apache-2.0 OR MIT", - "homepage": "https://github.com/ipfs/helia-routing-v1-http-api/tree/master/packages/interop#readme", + "homepage": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/tree/master/packages/interop#readme", "repository": { "type": "git", - "url": "git+https://github.com/ipfs/helia-routing-v1-http-api.git" + "url": "git+https://github.com/ipfs/helia-delegated-routing-v1-http-api.git" }, "bugs": { - "url": "https://github.com/ipfs/helia-routing-v1-http-api/issues" + "url": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues" }, "keywords": [ "IPFS" @@ -43,18 +43,19 @@ "test": "aegir test -t node", "test:node": "aegir test -t node --cov" }, - "dependencies": { - "@helia/routing-v1-http-api-client": "^1.0.0", - "@helia/routing-v1-http-api-server": "^1.0.0", - "helia": "^2.0.1" - }, "devDependencies": { + "@helia/delegated-routing-v1-http-api-client": "~0.0.0", + "@helia/delegated-routing-v1-http-api-server": "~0.0.0", "@helia/interface": "^2.0.0", + "@helia/ipns": "^2.0.1", "@libp2p/interface": "^0.1.2", "@libp2p/kad-dht": "^10.0.6", + "@libp2p/peer-id-factory": "^3.0.5", "aegir": "^41.0.0", "fastify": "^4.17.0", - "libp2p": "^0.46.10", + "helia": "^2.0.1", + "ipns": "^7.0.1", + "it-first": "^3.0.3", "multiformats": "^12.1.1" }, "private": true diff --git a/packages/interop/test/fixtures/create-helia.ts b/packages/interop/test/fixtures/create-helia.ts index 88e322b..164680c 100644 --- a/packages/interop/test/fixtures/create-helia.ts +++ b/packages/interop/test/fixtures/create-helia.ts @@ -1,6 +1,4 @@ -import { kadDHT } from '@libp2p/kad-dht' import { createHelia as createNode, type HeliaInit } from 'helia' -import { identifyService } from 'libp2p/identify' import type { Helia } from '@helia/interface' import type { Libp2p } from '@libp2p/interface' import type { KadDHT } from '@libp2p/kad-dht' @@ -8,11 +6,7 @@ import type { KadDHT } from '@libp2p/kad-dht' export async function createHelia (init?: Partial): Promise>> { const helia = await createNode({ libp2p: { - peerDiscovery: [], - services: { - identify: identifyService(), - dht: kadDHT() - } + peerDiscovery: [] } }) diff --git a/packages/interop/test/index.spec.ts b/packages/interop/test/index.spec.ts index 9000fbb..2261173 100644 --- a/packages/interop/test/index.spec.ts +++ b/packages/interop/test/index.spec.ts @@ -1,34 +1,39 @@ /* eslint-env mocha */ -import { createRoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client' -import { createRoutingV1HttpApiServer } from '@helia/routing-v1-http-api-server' +import { createDelegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' +import { createDelegatedRoutingV1HttpApiServer } from '@helia/delegated-routing-v1-http-api-server' +import { ipns } from '@helia/ipns' +import { dht } from '@helia/ipns/routing' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' +import { create as createIpnsRecord } from 'ipns' +import first from 'it-first' import { CID } from 'multiformats/cid' import * as raw from 'multiformats/codecs/raw' import { sha256 } from 'multiformats/hashes/sha2' import { createHelia } from './fixtures/create-helia.js' +import type { DelegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' import type { Helia } from '@helia/interface' -import type { RoutingV1HttpApiClient } from '@helia/routing-v1-http-api-client' import type { Libp2p } from '@libp2p/interface' import type { KadDHT } from '@libp2p/kad-dht' import type { FastifyInstance } from 'fastify' -describe('routing-v1-http-api interop', () => { +describe('delegated-routing-v1-http-api interop', () => { let network: Array>> let server: FastifyInstance - let client: RoutingV1HttpApiClient + let client: DelegatedRoutingV1HttpApiClient beforeEach(async () => { network = await Promise.all( new Array(10).fill(0).map(async () => createHelia()) ) - server = await createRoutingV1HttpApiServer(network[0]) + server = await createDelegatedRoutingV1HttpApiServer(network[0]) const address = server.server.address() const port = typeof address === 'string' ? address : address?.port - client = createRoutingV1HttpApiClient(new URL(`http://127.0.0.1:${port}`)) + client = createDelegatedRoutingV1HttpApiClient(new URL(`http://127.0.0.1:${port}`)) for (const node of network) { for (const remote of network) { @@ -64,7 +69,7 @@ describe('routing-v1-http-api interop', () => { for await (const prov of client.getProviders(cid)) { // should be a node in this test network - if (network.map(node => node.libp2p.peerId.toString()).includes(prov.id.toString())) { + if (network.map(node => node.libp2p.peerId.toString()).includes(prov.ID.toString())) { foundProvider = true break } @@ -72,4 +77,44 @@ describe('routing-v1-http-api interop', () => { expect(foundProvider).to.be.true() }) + + it('should find peer info', async () => { + const result = await first(client.getPeerInfo(network[2].libp2p.peerId)) + + if (result == null) { + throw new Error('PeerInfo not found') + } + + expect(result.ID.toString()).to.equal(network[2].libp2p.peerId.toString()) + }) + + it('should get an IPNS record', async () => { + // publish a record using a remote host + const i = ipns(network[5], [ + dht(network[5]) + ]) + const cid = CID.parse('bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354') + const peerId = await createEd25519PeerId() + await i.publish(peerId, cid) + + // use client to resolve the published record + const record = await client.getIPNS(peerId) + expect(record.value).to.equal(`/ipfs/${cid.toString()}`) + }) + + it('should put an IPNS record', async () => { + // publish a record using the client + const cid = CID.parse('bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354') + const peerId = await createEd25519PeerId() + const record = await createIpnsRecord(peerId, cid, 0, 1000 * 60 * 60 * 24) + + await client.putIPNS(peerId, record) + + // resolve the record using a remote host + const i = ipns(network[8], [ + dht(network[8]) + ]) + const result = await i.resolve(peerId) + expect(result.toString()).to.equal(cid.toString()) + }) }) diff --git a/packages/server/README.md b/packages/server/README.md index e276b76..f4e3a44 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -4,14 +4,16 @@

-# @helia/routing-v1-http-api-server +# @helia/delegated-routing-v1-http-api-server [![ipfs.tech](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](https://ipfs.tech) [![Discuss](https://img.shields.io/discourse/https/discuss.ipfs.tech/posts.svg?style=flat-square)](https://discuss.ipfs.tech) -[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-routing-v1-http-api) -[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) +[![codecov](https://img.shields.io/codecov/c/github/ipfs/helia-delegated-routing-v1-http-api.svg?style=flat-square)](https://codecov.io/gh/ipfs/helia-delegated-routing-v1-http-api) +[![CI](https://img.shields.io/github/actions/workflow/status/ipfs/helia-delegated-routing-v1-http-api/js-test-and-release.yml?branch=main\&style=flat-square)](https://github.com/ipfs/helia-delegated-routing-v1-http-api/actions/workflows/js-test-and-release.yml?query=branch%3Amain) -> A Routing V1 HTTP API server powered by Helia +> A Delegated Routing V1 HTTP API server powered by Helia + +A server implementation of the IPFS [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/). ## Table of contents @@ -23,12 +25,12 @@ ## Install ```console -$ npm i @helia/routing-v1-http-api-server +$ npm i @helia/delegated-routing-v1-http-api-server ``` ## API Docs -- +- ## License @@ -39,7 +41,7 @@ Licensed under either of ## Contribute -Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-routing-v1-http-api/issues). +Contributions welcome! Please check out [the issues](https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues). Also see our [contributing document](https://github.com/ipfs/community/blob/master/CONTRIBUTING_JS.md) for more information on how we work, and about contributing in general. diff --git a/packages/server/package.json b/packages/server/package.json index 4c47219..e359203 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,15 +1,15 @@ { - "name": "@helia/routing-v1-http-api-server", - "version": "1.0.3", - "description": "A Routing V1 HTTP API server powered by Helia", + "name": "@helia/delegated-routing-v1-http-api-server", + "version": "0.0.0", + "description": "A Delegated Routing V1 HTTP API server powered by Helia", "license": "Apache-2.0 OR MIT", - "homepage": "https://github.com/ipfs/helia-routing-v1-http-api/tree/master/packages/server#readme", + "homepage": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/tree/master/packages/server#readme", "repository": { "type": "git", - "url": "git+https://github.com/ipfs/helia-routing-v1-http-api.git" + "url": "git+https://github.com/ipfs/helia-delegated-routing-v1-http-api.git" }, "bugs": { - "url": "https://github.com/ipfs/helia-routing-v1-http-api/issues" + "url": "https://github.com/ipfs/helia-delegated-routing-v1-http-api/issues" }, "keywords": [ "IPFS" @@ -153,11 +153,14 @@ "@fastify/cors": "^8.3.0", "@helia/interface": "^2.0.0", "@libp2p/interface": "^0.1.2", + "@libp2p/peer-id": "^3.0.3", "fastify": "^4.17.0", - "multiformats": "^12.1.1" + "ipns": "^7.0.1", + "multiformats": "^12.1.1", + "raw-body": "^2.5.2" }, "devDependencies": { - "@libp2p/peer-id-factory": "^3.0.3", + "@libp2p/peer-id-factory": "^3.0.5", "@multiformats/multiaddr": "^12.1.3", "@types/sinon": "^10.0.15", "aegir": "^41.0.0", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1bcc253..bf52b62 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -67,7 +67,7 @@ export interface ServerInit { /** * Create and return a Routing V1 HTTP API server */ -export async function createRoutingV1HttpApiServer (helia: Helia, init: ServerInit = {}): Promise { +export async function createDelegatedRoutingV1HttpApiServer (helia: Helia, init: ServerInit = {}): Promise { const server = init.fastify ?? fastify() await server.register(cors, { origin: '*', diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 959e4ff..69b027f 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -1,7 +1,13 @@ +import getIpnsV1 from './routing/v1/ipns/get.js' +import putIpnsV1 from './routing/v1/ipns/put.js' +import getPeersV1 from './routing/v1/peers/get.js' import getProvidersV1 from './routing/v1/providers/get.js' import type { Helia } from '@helia/interface' import type { FastifyInstance } from 'fastify' export default function routes (fastify: FastifyInstance, helia: Helia): void { getProvidersV1(fastify, helia) + getPeersV1(fastify, helia) + getIpnsV1(fastify, helia) + putIpnsV1(fastify, helia) } diff --git a/packages/server/src/routes/routing/v1/ipns/get.ts b/packages/server/src/routes/routing/v1/ipns/get.ts new file mode 100644 index 0000000..57ff1c3 --- /dev/null +++ b/packages/server/src/routes/routing/v1/ipns/get.ts @@ -0,0 +1,56 @@ +import { peerIdFromCID } from '@libp2p/peer-id' +import { peerIdToRoutingKey } from 'ipns' +import { CID } from 'multiformats/cid' +import type { Helia } from '@helia/interface' +import type { PeerId } from '@libp2p/interface/peer-id' +import type { FastifyInstance } from 'fastify' + +interface Params { + name: string +} + +export default function getIpnsV1 (fastify: FastifyInstance, helia: Helia): void { + fastify.route<{ Params: Params }>({ + method: 'GET', + url: '/routing/v1/ipns/:name', + schema: { + // request needs to have a querystring with a `name` parameter + params: { + type: 'object', + properties: { + name: { + type: 'string' + } + }, + required: ['name'] + } + }, + handler: async (request, reply) => { + let peerId: PeerId + const controller = new AbortController() + + request.raw.on('close', () => { + controller.abort() + }) + + try { + // PeerId must be encoded as a Libp2p-key CID. + const { name: cidStr } = request.params + const peerCid = CID.parse(cidStr) + peerId = peerIdFromCID(peerCid) + } catch (err) { + fastify.log.error('could not parse CID from params', err) + return reply.code(422).type('text/html').send('Unprocessable Entity') + } + + const rawRecord = await helia.libp2p.contentRouting.get(peerIdToRoutingKey(peerId), { + signal: controller.signal + }) + + return reply + .header('Content-Type', 'application/vnd.ipfs.ipns-record') + // one cannot simply send rawRecord https://github.com/fastify/fastify/issues/5118 + .send(Buffer.from(rawRecord, 0, rawRecord.byteLength)) + } + }) +} diff --git a/packages/server/src/routes/routing/v1/ipns/put.ts b/packages/server/src/routes/routing/v1/ipns/put.ts new file mode 100644 index 0000000..c603107 --- /dev/null +++ b/packages/server/src/routes/routing/v1/ipns/put.ts @@ -0,0 +1,65 @@ +import { peerIdFromCID } from '@libp2p/peer-id' +import { peerIdToRoutingKey } from 'ipns' +import { ipnsValidator } from 'ipns/validator' +import { CID } from 'multiformats/cid' +import getRawBody from 'raw-body' +import type { Helia } from '@helia/interface' +import type { PeerId } from '@libp2p/interface/peer-id' +import type { FastifyInstance } from 'fastify' + +interface Params { + name: string +} + +export default function putIpnsV1 (fastify: FastifyInstance, helia: Helia): void { + fastify.addContentTypeParser('application/vnd.ipfs.ipns-record', function (request, payload, done) { + getRawBody(payload) + .then(buff => { done(null, buff) }) + .catch(err => { done(err) }) + }) + + fastify.route<{ Params: Params }>({ + method: 'PUT', + url: '/routing/v1/ipns/:name', + schema: { + // request needs to have a querystring with a `name` parameter + params: { + type: 'object', + properties: { + name: { + type: 'string' + } + }, + required: ['name'] + } + }, + handler: async (request, reply) => { + let peerId: PeerId + const controller = new AbortController() + + request.raw.on('close', () => { + controller.abort() + }) + + try { + // PeerId must be encoded as a Libp2p-key CID. + const { name: cidStr } = request.params + const peerCid = CID.parse(cidStr) + peerId = peerIdFromCID(peerCid) + } catch (err) { + fastify.log.error('could not parse CID from params', err) + return reply.code(422).type('text/html').send('Unprocessable Entity') + } + + // @ts-expect-error request.body does not have a type + const body: Uint8Array = request.body + await ipnsValidator(peerIdToRoutingKey(peerId), body) + + await helia.libp2p.contentRouting.put(peerIdToRoutingKey(peerId), body, { + signal: controller.signal + }) + + return reply.send() + } + }) +} diff --git a/packages/server/src/routes/routing/v1/peers/get.ts b/packages/server/src/routes/routing/v1/peers/get.ts new file mode 100644 index 0000000..9aaf980 --- /dev/null +++ b/packages/server/src/routes/routing/v1/peers/get.ts @@ -0,0 +1,72 @@ +import { PassThrough } from 'node:stream' +import { peerIdFromCID } from '@libp2p/peer-id' +import { CID } from 'multiformats/cid' +import type { Helia } from '@helia/interface' +import type { PeerId } from '@libp2p/interface/peer-id' +import type { FastifyInstance } from 'fastify' + +interface Params { + peerId: string +} + +export default function getPeersV1 (fastify: FastifyInstance, helia: Helia): void { + fastify.route<{ Params: Params }>({ + method: 'GET', + url: '/routing/v1/peers/:peerId', + schema: { + // request needs to have a querystring with a `name` parameter + params: { + type: 'object', + properties: { + peerId: { + type: 'string' + } + }, + required: ['peerId'] + } + }, + handler: async (request, reply) => { + let peerId: PeerId + const controller = new AbortController() + + request.raw.on('close', () => { + controller.abort() + }) + + try { + const { peerId: cidStr } = request.params + const peerCid = CID.parse(cidStr) + peerId = peerIdFromCID(peerCid) + } catch (err) { + fastify.log.error('could not parse CID from params', err) + return reply.code(422).type('text/html').send('Unprocessable Entity') + } + + const peerInfo = await helia.libp2p.peerRouting.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() + + // 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] + }) + } + } + }) +} diff --git a/packages/server/src/routes/routing/v1/providers/get.ts b/packages/server/src/routes/routing/v1/providers/get.ts index 4d9e6f3..184cd5d 100644 --- a/packages/server/src/routes/routing/v1/providers/get.ts +++ b/packages/server/src/routes/routing/v1/providers/get.ts @@ -8,15 +8,15 @@ interface Params { cid: string } -interface Provider { - Protocol: string +interface PeerRecord { Schema: string + Protocols?: string[] ID: string Addrs: string[] } interface Providers { - Providers: Provider[] + Providers: PeerRecord[] } const MAX_PROVIDERS = 100 @@ -39,48 +39,57 @@ export default function getProvidersV1 (fastify: FastifyInstance, helia: Helia): }, handler: async (request, reply) => { let cid: CID + const controller = new AbortController() + + request.raw.on('close', () => { + controller.abort() + }) try { const { cid: cidStr } = request.params cid = CID.parse(cidStr) } catch (err) { - // these are .thenables but not .catchables? - reply.code(422).type('text/html').send('Unprocessable Entity') // eslint-disable-line @typescript-eslint/no-floating-promises - return + fastify.log.error('could not parse CID from params', err) + return reply.code(422).type('text/html').send('Unprocessable Entity') } if (request.headers.accept?.includes('application/x-ndjson') === true) { const stream = new PassThrough() - try { - let found = 0 + // wait until we have the first result + const iterable = streamingHandler(cid, helia, { + signal: controller.signal + }) + const result = await iterable.next() - for await (const prov of streamingHandler(cid, helia)) { - if (found === 0) { - // these are .thenables but not .catchables? - reply.header('Content-Type', 'application/x-ndjson') // eslint-disable-line @typescript-eslint/no-floating-promises - reply.send(stream) // eslint-disable-line @typescript-eslint/no-floating-promises - } + // if we have a value, send the value in a stream + if (result.done !== true) { + stream.push(JSON.stringify(result.value) + '\n') - found++ - - stream.push(JSON.stringify(prov) + '\n') - } - - if (found > 0) { - return - } - } finally { - stream.end() + // iterate over the rest of the results + void Promise.resolve().then(async () => { + for await (const prov of iterable) { + stream.push(JSON.stringify(prov) + '\n') + } + }) + .catch(err => { + fastify.log.error('could send stream of providers', err) + }) + .finally(() => { + stream.end() + }) + + return reply + .header('Content-Type', 'application/x-ndjson') + .send(stream) } } else { - const result = await nonStreamingHandler(cid, helia) + const result = await nonStreamingHandler(cid, helia, { + signal: controller.signal + }) if (result.Providers.length > 0) { - // this is .thenable but not .catchable? - reply.header('Content-Type', 'application/json') // eslint-disable-line @typescript-eslint/no-floating-promises - - return reply.send(result) + return reply.header('Content-Type', 'application/json').send(result) } } @@ -89,13 +98,12 @@ export default function getProvidersV1 (fastify: FastifyInstance, helia: Helia): }) } -async function * streamingHandler (cid: CID, helia: Helia, options?: AbortOptions): AsyncGenerator { +async function * streamingHandler (cid: CID, helia: Helia, options?: AbortOptions): AsyncGenerator { let provs = 0 for await (const prov of helia.libp2p.contentRouting.findProviders(cid, options)) { yield { - Protocol: 'transport-bitswap', - Schema: 'bitswap', + Schema: 'peer', ID: prov.id.toString(), Addrs: prov.multiaddrs.map(ma => ma.toString()) } @@ -114,8 +122,7 @@ async function nonStreamingHandler (cid: CID, helia: Helia, options?: AbortOptio try { for await (const prov of helia.libp2p.contentRouting.findProviders(cid, options)) { providers.push({ - Protocol: 'transport-bitswap', - Schema: 'bitswap', + Schema: 'peer', ID: prov.id.toString(), Addrs: prov.multiaddrs.map(ma => ma.toString()) }) diff --git a/packages/server/test/index.spec.ts b/packages/server/test/index.spec.ts index 652eb13..6fc1ac2 100644 --- a/packages/server/test/index.spec.ts +++ b/packages/server/test/index.spec.ts @@ -3,21 +3,23 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' +import { create as createIpnsRecord, marshal as marshalIpnsRecord, peerIdToRoutingKey } from 'ipns' +import { CID } from 'multiformats' import { stubInterface } from 'sinon-ts' -import { createRoutingV1HttpApiServer } from '../src/index.js' +import { createDelegatedRoutingV1HttpApiServer } from '../src/index.js' import type { Helia } from '@helia/interface' import type { PeerInfo } from '@libp2p/interface/peer-info' import type { FastifyInstance } from 'fastify' import type { StubbedInstance } from 'sinon-ts' -describe('routing-v1-http-api-server', () => { +describe('delegated-routing-v1-http-api-server', () => { let helia: StubbedInstance let server: FastifyInstance let url: URL beforeEach(async () => { helia = stubInterface() - server = await createRoutingV1HttpApiServer(helia, { + server = await createDelegatedRoutingV1HttpApiServer(helia, { listen: { host: '127.0.0.1', port: 0 @@ -47,7 +49,7 @@ describe('routing-v1-http-api-server', () => { expect(res.headers.get('access-control-allow-methods')).to.equal('GET, OPTIONS') }) - it('returns 422 if the CID is invalid', async () => { + it('GET providers returns 422 if the CID is invalid', async () => { const res = await fetch(`${url}routing/v1/providers/derp`, { method: 'GET' }) @@ -55,7 +57,7 @@ describe('routing-v1-http-api-server', () => { expect(res.status).to.equal(422) }) - it('returns 404 if the CID is missing', async () => { + it('GET providers returns 404 if the CID is missing', async () => { const res = await fetch(`${url}routing/v1/providers`, { method: 'GET' }) @@ -63,7 +65,7 @@ describe('routing-v1-http-api-server', () => { expect(res.status).to.equal(404) }) - it('returns 404 if no providers are found', async () => { + it('GET providers returns 404 if no providers are found', async () => { helia.libp2p = { // @ts-expect-error incomplete implementation contentRouting: { @@ -78,7 +80,7 @@ describe('routing-v1-http-api-server', () => { expect(res.status).to.equal(404) }) - it('returns 404 if no providers are found when streaming', async () => { + it('GET providers returns 404 if no providers are found when streaming', async () => { helia.libp2p = { // @ts-expect-error incomplete implementation contentRouting: { @@ -96,7 +98,7 @@ describe('routing-v1-http-api-server', () => { expect(res.status).to.equal(404) }) - it('returns providers', async () => { + it('GET providers returns providers', async () => { const provider1: PeerInfo = { id: await createEd25519PeerId(), multiaddrs: [ @@ -130,17 +132,15 @@ describe('routing-v1-http-api-server', () => { const json = await res.json() - expect(json).to.have.nested.property('Providers[0].Protocol', 'transport-bitswap') - expect(json).to.have.nested.property('Providers[0].Schema', 'bitswap') + expect(json).to.have.nested.property('Providers[0].Schema', 'peer') expect(json).to.have.nested.property('Providers[0].ID', provider1.id.toString()) expect(json).to.have.deep.nested.property('Providers[0].Addrs', provider1.multiaddrs.map(ma => ma.toString())) - expect(json).to.have.nested.property('Providers[1].Protocol', 'transport-bitswap') - expect(json).to.have.nested.property('Providers[1].Schema', 'bitswap') + expect(json).to.have.nested.property('Providers[1].Schema', 'peer') expect(json).to.have.nested.property('Providers[1].ID', provider2.id.toString()) expect(json).to.have.deep.nested.property('Providers[1].Addrs', provider2.multiaddrs.map(ma => ma.toString())) }) - it('returns provider stream', async () => { + it('GET providers returns provider stream', async () => { const provider1: PeerInfo = { id: await createEd25519PeerId(), multiaddrs: [ @@ -180,13 +180,130 @@ describe('routing-v1-http-api-server', () => { .filter(Boolean) .map(str => JSON.parse(str)) - expect(json).to.have.nested.property('[0].Protocol', 'transport-bitswap') - expect(json).to.have.nested.property('[0].Schema', 'bitswap') + expect(json).to.have.nested.property('[0].Schema', 'peer') expect(json).to.have.nested.property('[0].ID', provider1.id.toString()) expect(json).to.have.deep.nested.property('[0].Addrs', provider1.multiaddrs.map(ma => ma.toString())) - expect(json).to.have.nested.property('[1].Protocol', 'transport-bitswap') - expect(json).to.have.nested.property('[1].Schema', 'bitswap') + expect(json).to.have.nested.property('[1].Schema', 'peer') expect(json).to.have.nested.property('[1].ID', provider2.id.toString()) expect(json).to.have.deep.nested.property('[1].Addrs', provider2.multiaddrs.map(ma => ma.toString())) }) + + it('GET peers returns 422 if peer id is not cid', async () => { + const res = await fetch(`${url}routing/v1/peers/${(await createEd25519PeerId()).toString()}`, { + method: 'GET' + }) + + expect(res.status).to.equal(422) + }) + + it('GET peers returns 422 if cid is not peer id', async () => { + const res = await fetch(`${url}routing/v1/peers/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4`, { + method: 'GET' + }) + + expect(res.status).to.equal(422) + }) + + it('GET peers returns peer records for get peers', async () => { + const peer: PeerInfo = { + id: await createEd25519PeerId(), + multiaddrs: [ + multiaddr('/ip4/123.123.123.123/tcp/123') + ], + protocols: ['transport-bitswap'] + } + + helia.libp2p = { + // @ts-expect-error incomplete implementation + peerRouting: { + findPeer: async function () { + return peer + } + } + } + + const res = await fetch(`${url}routing/v1/peers/${peer.id.toCID().toString()}`, { + method: 'GET' + }) + expect(res.status).to.equal(200) + + const json = await res.json() + expect(json).to.have.nested.property('Peers[0].Schema', 'peer') + expect(json).to.have.nested.property('Peers[0].ID', peer.id.toString()) + expect(json).to.have.deep.nested.property('Peers[0].Addrs', peer.multiaddrs.map(ma => ma.toString())) + }) + + it('GET ipns returns 422 if peer id is not cid', async () => { + const res = await fetch(`${url}routing/v1/ipns/${(await createEd25519PeerId()).toString()}`, { + method: 'GET' + }) + + expect(res.status).to.equal(422) + }) + + it('GET ipns returns 422 if cid is not peer id', async () => { + const res = await fetch(`${url}routing/v1/ipns/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4`, { + method: 'GET' + }) + + expect(res.status).to.equal(422) + }) + + it('GET ipns returns record', async () => { + const peerId = await createEd25519PeerId() + const cid = CID.parse('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4') + const record = await createIpnsRecord(peerId, cid, 0, 1000) + + helia.libp2p = { + // @ts-expect-error incomplete implementation + contentRouting: { + get: async function () { + return marshalIpnsRecord(record) + } + } + } + + 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) + const arrayBuffer = await res.arrayBuffer() + expect(new Uint8Array(arrayBuffer)).to.equalBytes(marshalIpnsRecord(record)) + }) + + it('PUT ipns puts record', async () => { + const peerId = await createEd25519PeerId() + const cid = CID.parse('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4') + const record = await createIpnsRecord(peerId, cid, 0, 1000) + const marshalledRecord = marshalIpnsRecord(record) + + let putKey: Uint8Array = new Uint8Array() + let putValue: Uint8Array = new Uint8Array() + + helia.libp2p = { + // @ts-expect-error incomplete implementation + contentRouting: { + put: async function (key: Uint8Array, value: Uint8Array) { + putKey = key + putValue = value + } + } + } + + const res = await fetch(`${url}routing/v1/ipns/${peerId.toCID().toString()}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/vnd.ipfs.ipns-record' + }, + body: marshalledRecord + }) + + expect(res.status).to.equal(200) + expect(putKey).to.equalBytes(peerIdToRoutingKey(peerId)) + expect(putValue).to.equalBytes(marshalledRecord) + }) })