From 1e874c2294f9032e2575f65170962c564456b440 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 30 Jan 2021 03:16:12 +0100 Subject: [PATCH] feat: support remote pinning services in ipfs-http-client (#3293) Implement [remote pinning service API](https://github.com/ipfs/pinning-services-api-spec) in ipfs-http-client. --- package.json | 2 +- src/pin/index.js | 5 +- src/pin/remote/index.js | 208 ++++++++++++++++++++++++++++++++++++++ src/pin/remote/service.js | 162 +++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/pin/remote/index.js create mode 100644 src/pin/remote/service.js diff --git a/package.json b/package.json index 829bbbc2a..b20586ac1 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "devDependencies": { "aegir": "^29.2.2", "delay": "^4.4.0", - "go-ipfs": "^0.7.0", + "go-ipfs": "0.8.0-rc2", "ipfs-core": "^0.4.2", "ipfsd-ctl": "^7.2.0", "it-all": "^1.0.4", diff --git a/src/pin/index.js b/src/pin/index.js index 39f7b5ecc..e27a8af50 100644 --- a/src/pin/index.js +++ b/src/pin/index.js @@ -1,9 +1,12 @@ 'use strict' +const Remote = require('./remote') + module.exports = config => ({ add: require('./add')(config), addAll: require('./add-all')(config), ls: require('./ls')(config), rm: require('./rm')(config), - rmAll: require('./rm-all')(config) + rmAll: require('./rm-all')(config), + remote: new Remote(config) }) diff --git a/src/pin/remote/index.js b/src/pin/remote/index.js new file mode 100644 index 000000000..0019020f6 --- /dev/null +++ b/src/pin/remote/index.js @@ -0,0 +1,208 @@ +'use strict' + +const CID = require('cids') +const Client = require('../../lib/core') +const Service = require('./service') +const toUrlSearchParams = require('../../lib/to-url-search-params') + +/** + * @typedef {import('../..').HttpOptions} HttpOptions + * @typedef {import('../../lib/core').ClientOptions} ClientOptions + * @typedef {import('ipfs-core-types/src/basic').AbortOptions} AbortOptions + * @typedef {import('ipfs-core-types/src/pin/remote').API} API + * @typedef {import('ipfs-core-types/src/pin/remote').Pin} Pin + * @typedef {import('ipfs-core-types/src/pin/remote').AddOptions} AddOptions + * @typedef {import('ipfs-core-types/src/pin/remote').Query} Query + * @typedef {import('ipfs-core-types/src/pin/remote').Status} Status + * + * @implements {API} + */ +class Remote { + /** + * @param {ClientOptions} options + */ + constructor (options) { + /** @private */ + this.client = new Client(options) + /** @readonly */ + this.service = new Service(options) + } + + /** + * Stores an IPFS object(s) from a given path to a remote pinning service. + * + * @param {CID} cid + * @param {AddOptions & AbortOptions & HttpOptions} options + * @returns {Promise} + */ + add (cid, options) { + return Remote.add(this.client, cid, options) + } + + /** + * @param {Client} client + * @param {CID} cid + * @param {AddOptions & AbortOptions & HttpOptions} options + */ + static async add (client, cid, { timeout, signal, headers, ...options }) { + const response = await client.post('pin/remote/add', { + timeout, + signal, + headers, + searchParams: encodeAddParams({ cid, ...options }) + }) + + return Remote.decodePin(await response.json()) + } + + /** + * @param {Object} json + * @param {string} json.Name + * @param {string} json.Cid + * @param {Status} json.Status + * @returns {Pin} + */ + static decodePin ({ Name: name, Status: status, Cid: cid }) { + return { + cid: new CID(cid), + name, + status + } + } + + /** + * Returns a list of matching pins on the remote pinning service. + * + * @param {Query & AbortOptions & HttpOptions} query + */ + ls (query) { + return Remote.ls(this.client, query) + } + + /** + * + * @param {Client} client + * @param {Query & AbortOptions & HttpOptions} options + * @returns {AsyncIterable} + */ + static async * ls (client, { timeout, signal, headers, ...query }) { + const response = await client.post('pin/remote/ls', { + signal, + timeout, + headers, + searchParams: encodeQuery(query) + }) + + for await (const pin of response.ndjson()) { + yield Remote.decodePin(pin) + } + } + + /** + * Removes a single pin object matching query allowing it to be garbage + * collected (if needed). Will error if multiple pins mtach provided + * query. To remove all matches use `rmAll` instead. + * + * @param {Query & AbortOptions & HttpOptions} query + */ + rm (query) { + return Remote.rm(this.client, { ...query, all: false }) + } + + /** + * Removes all pin object that match given query allowing them to be garbage + * collected if needed. + * + * @param {Query & AbortOptions & HttpOptions} query + */ + rmAll (query) { + return Remote.rm(this.client, { ...query, all: true }) + } + + /** + * + * @param {Client} client + * @param {{all: boolean} & Query & AbortOptions & HttpOptions} options + */ + static async rm (client, { timeout, signal, headers, ...query }) { + await client.post('pin/remote/rm', { + timeout, + signal, + headers, + searchParams: encodeQuery(query) + }) + } +} + +/** + * @param {any} service + * @returns {string} + */ +const encodeService = (service) => { + if (typeof service === 'string' && service !== '') { + return service + } else { + throw new TypeError('service name must be passed') + } +} + +/** + * @param {any} cid + * @returns {string} + */ +const encodeCID = (cid) => { + if (CID.isCID(cid)) { + return cid.toString() + } else { + throw new TypeError(`CID instance expected instead of ${cid}`) + } +} + +/** + * @param {Query & { all?: boolean }} query + * @returns {URLSearchParams} + */ +const encodeQuery = ({ service, cid, name, status, all }) => { + const query = toUrlSearchParams({ + service: encodeService(service), + name, + force: all ? true : undefined + }) + + if (cid) { + for (const value of cid) { + query.append('cid', encodeCID(value)) + } + } + + if (status) { + for (const value of status) { + query.append('status', value) + } + } + + return query +} + +/** + * @param {AddOptions & {cid:CID}} options + * @returns {URLSearchParams} + */ +const encodeAddParams = ({ cid, service, background, name, origins }) => { + const params = toUrlSearchParams({ + arg: encodeCID(cid), + service: encodeService(service), + name, + background: background ? true : undefined + }) + + if (origins) { + for (const origin of origins) { + params.append('origin', origin.toString()) + } + } + + return params +} + +module.exports = Remote diff --git a/src/pin/remote/service.js b/src/pin/remote/service.js new file mode 100644 index 000000000..9a718e847 --- /dev/null +++ b/src/pin/remote/service.js @@ -0,0 +1,162 @@ +'use strict' + +const Client = require('../../lib/core') +const toUrlSearchParams = require('../../lib/to-url-search-params') + +/** + * @typedef {import('../../lib/core').ClientOptions} ClientOptions + * @typedef {import('../..').HttpOptions} HttpOptions + * @typedef {import('ipfs-core-types/src/basic').AbortOptions} AbortOptions + * @typedef {import('ipfs-core-types/src/pin/remote/service').API} API + * @typedef {import('ipfs-core-types/src/pin/remote/service').Credentials} Credentials + * @typedef {import('ipfs-core-types/src/pin/remote/service').RemotePinService} RemotePinService + * @typedef {import('ipfs-core-types/src/pin/remote/service').RemotePinServiceWithStat} RemotePinServiceWithStat + * @implements {API} + */ +class Service { + /** + * @param {ClientOptions} options + */ + constructor (options) { + /** @private */ + this.client = new Client(options) + } + + /** + * @param {Client} client + * @param {string} name + * @param {Credentials & AbortOptions & HttpOptions} options + */ + static async add (client, name, options) { + const { endpoint, key, headers, timeout, signal } = options + await client.post('pin/remote/service/add', { + timeout, + signal, + searchParams: toUrlSearchParams({ + arg: [name, Service.encodeEndpoint(endpoint), key] + }), + headers + }) + } + + /** + * @param {URL} url + */ + static encodeEndpoint (url) { + const href = String(url) + if (href === 'undefined') { + throw Error('endpoint is required') + } + // Workaround trailing `/` issue in go-ipfs + // @see https://github.com/ipfs/go-ipfs/issues/7826 + return href[href.length - 1] === '/' ? href.slice(0, -1) : href + } + + /** + * @param {Client} client + * @param {string} name + * @param {AbortOptions & HttpOptions} [options] + */ + static async rm (client, name, { timeout, signal, headers } = {}) { + await client.post('pin/remote/service/rm', { + timeout, + signal, + headers, + searchParams: toUrlSearchParams({ + arg: name + }) + }) + } + + /** + * @template {true} Stat + * @param {Client} client + * @param {{ stat?: Stat } & AbortOptions & HttpOptions} [options] + */ + static async ls (client, { stat, timeout, signal, headers } = {}) { + const response = await client.post('pin/remote/service/ls', { + searchParams: stat === true ? toUrlSearchParams({ stat }) : undefined, + timeout, + signal, + headers + }) + + /** @type {{RemoteServices: Object[]}} */ + const { RemoteServices } = await response.json() + + /** @type {Stat extends true ? RemotePinServiceWithStat[] : RemotePinService []} */ + return (RemoteServices.map(Service.decodeRemoteService)) + } + + /** + * @param {Object} json + * @returns {RemotePinServiceWithStat} + */ + static decodeRemoteService (json) { + return { + service: json.Service, + endpoint: new URL(json.ApiEndpoint), + ...(json.Stat && { stat: Service.decodeStat(json.Stat) }) + } + } + + /** + * @param {Object} json + * @returns {import('ipfs-core-types/src/pin/remote/service').Stat} + */ + static decodeStat (json) { + switch (json.Status) { + case 'valid': { + const { Pinning, Pinned, Queued, Failed } = json.PinCount + return { + status: 'valid', + pinCount: { + queued: Queued, + pinning: Pinning, + pinned: Pinned, + failed: Failed + } + } + } + case 'invalid': { + return { status: 'invalid' } + } + default: { + return { status: json.Status } + } + } + } + + /** + * Registers remote pinning service with a given name. Errors if service + * with the given name is already registered. + * + * @param {string} name + * @param {Credentials & AbortOptions & HttpOptions} options + */ + add (name, options) { + return Service.add(this.client, name, options) + } + + /** + * Unregisteres remote pinning service with a given name. If service with such + * name isn't registerede this is a noop. + * + * @param {string} name + * @param {AbortOptions & HttpOptions} [options] + */ + rm (name, options) { + return Service.rm(this.client, name, options) + } + + /** + * List registered remote pinning services. + * + * @param {{ stat?: true } & AbortOptions & HttpOptions} [options] + */ + ls (options) { + return Service.ls(this.client, options) + } +} + +module.exports = Service