Skip to content

Commit

Permalink
feat: support remote pinning services in ipfs-http-client (#3293)
Browse files Browse the repository at this point in the history
Implement [remote pinning service API](https://github.com/ipfs/pinning-services-api-spec) in ipfs-http-client.
  • Loading branch information
lidel authored Jan 30, 2021
1 parent fdefd11 commit 1e874c2
Show file tree
Hide file tree
Showing 4 changed files with 375 additions and 2 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/pin/index.js
Original file line number Diff line number Diff line change
@@ -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)
})
208 changes: 208 additions & 0 deletions src/pin/remote/index.js
Original file line number Diff line number Diff line change
@@ -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<Pin>}
*/
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<Pin>}
*/
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
162 changes: 162 additions & 0 deletions src/pin/remote/service.js
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 1e874c2

Please sign in to comment.