From 946f455c200176b754c74b231c18a09234be6d87 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 13 Mar 2019 19:53:47 +0000 Subject: [PATCH] feat: adds http DAG api --- src/http/api/resources/dag.js | 239 ++++++++++++++++++++++++++++++++ src/http/api/resources/index.js | 1 + src/http/api/routes/dag.js | 43 ++++++ src/http/api/routes/index.js | 1 + test/http-api/dag.js | 111 +++++++++++++++ test/http-api/index.js | 1 + test/http-api/interface.js | 4 +- 7 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 src/http/api/resources/dag.js create mode 100644 src/http/api/routes/dag.js create mode 100644 test/http-api/dag.js diff --git a/src/http/api/resources/dag.js b/src/http/api/resources/dag.js new file mode 100644 index 0000000000..5eb77b47a4 --- /dev/null +++ b/src/http/api/resources/dag.js @@ -0,0 +1,239 @@ +'use strict' + +const promisify = require('promisify-es6') +const CID = require('cids') +const multipart = require('ipfs-multipart') +const Joi = require('joi') +const multibase = require('multibase') +const Boom = require('boom') +const debug = require('debug') +const log = debug('ipfs:http-api:dag') +log.error = debug('ipfs:http-api:dag:error') + +// common pre request handler that parses the args and returns `key` which is assigned to `request.pre.args` +exports.parseKey = (request, h) => { + if (!request.query.arg) { + throw Boom.badRequest("Argument 'key' is required") + } + + let key = request.query.arg.trim() + let path + + if (key.startsWith('/ipfs')) { + key = key.substring(5) + } + + const parts = key.split('/') + + if (parts.length > 1) { + key = parts.shift() + path = `${parts.join('/')}` + } + + if (path.endsWith('/')) { + path = path.substring(0, path.length - 1) + } + + try { + return { + key: new CID(key), + path + } + } catch (err) { + log.error(err) + throw Boom.badRequest("invalid 'ipfs ref' path") + } +} + +exports.get = { + validate: { + query: Joi.object().keys({ + 'data-encoding': Joi.string().valid(['text', 'base64']).default('base64'), + 'cid-base': Joi.string().valid(multibase.names) + }).unknown() + }, + + // uses common parseKey method that returns a `key` + parseArgs: exports.parseKey, + + // main route handler which is called after the above `parseArgs`, but only if the args were valid + async handler (request, h) { + const { + key, + path + } = request.pre.args + const { ipfs } = request.server.app + + let result + + try { + result = await ipfs.dag.get(key, path) + } catch (err) { + throw Boom.boomify(err, { message: 'Failed to get dag node' }) + } + + if (key.codec === 'dag-pb' && result.value) { + if (typeof result.value.toJSON === 'function') { + result.value = result.value.toJSON() + } + + if (Buffer.isBuffer(result.value.data)) { + result.value.data = result.value.data.toString(request.query.dataencoding) + } + } + + return h.response(result.value) + } +} + +exports.put = { + validate: { + query: Joi.object().keys({ + // TODO validate format, & hash + format: Joi.string(), + 'input-enc': Joi.string().valid('dag-cbor', 'dag-pb', 'raw'), + pin: Joi.boolean(), + hash: Joi.string(), + 'cid-base': Joi.string().valid(multibase.names).default('base58btc') + }).unknown() + }, + + // pre request handler that parses the args and returns `node` + // which is assigned to `request.pre.args` + async parseArgs (request, h) { + if (!request.payload) { + throw Boom.badRequest("File argument 'data' is required") + } + + const enc = request.query.inputenc + + const fileStream = await new Promise((resolve, reject) => { + multipart.reqParser(request.payload) + .on('file', (name, stream) => resolve(stream)) + .on('end', () => reject(Boom.badRequest("File argument 'data' is required"))) + }) + + let data = await new Promise((resolve, reject) => { + fileStream + .on('data', data => resolve(data)) + .on('end', () => reject(Boom.badRequest("File argument 'data' is required"))) + }) + + if (enc === 'json') { + try { + data = JSON.parse(data.toString()) + } catch (err) { + throw Boom.badRequest('Failed to parse the JSON: ' + err) + } + } + + try { + return { + buffer: data + } + } catch (err) { + throw Boom.badRequest('Failed to create DAG node: ' + err) + } + }, + + // main route handler which is called after the above `parseArgs`, but only if the args were valid + async handler (request, h) { + const { ipfs } = request.server.app + const { buffer } = request.pre.args + + let cid + + return new Promise((resolve, reject) => { + const format = ipfs._ipld.resolvers[request.query.format] + + if (!format) { + return reject(Boom.badRequest(`Missing IPLD format "${request.query.format}"`)) + } + + format.util.deserialize(buffer, async (err, node) => { + if (err) { + return reject(err) + } + + try { + cid = await ipfs.dag.put(node, { + format: request.query.format, + hashAlg: request.query.hash + }) + } catch (err) { + throw Boom.boomify(err, { message: 'Failed to put node' }) + } + + if (request.query.pin) { + await ipfs.pin.add(cid) + } + + resolve(h.response({ + Cid: { + '/': cid.toBaseEncodedString(request.query.cidbase) + } + })) + }) + }) + } +} + +exports.resolve = { + validate: { + query: Joi.object().keys({ + 'cid-base': Joi.string().valid(multibase.names) + }).unknown() + }, + + // uses common parseKey method that returns a `key` + parseArgs: exports.parseKey, + + // main route handler which is called after the above `parseArgs`, but only if the args were valid + async handler (request, h) { + let { key, path } = request.pre.args + const cidBase = request.query['cid-base'] + const { ipfs } = request.server.app + + console.info('lkoading', key.toBaseEncodedString(), path) + + // to be consistent with go we need to return the CID to the last node we've traversed + // along with the path inside that node as the remainder path + try { + let lastCid = key + let lastRemainderPath = path + + while (true) { + const block = await ipfs.block.get(lastCid) + const codec = ipfs._ipld.resolvers[lastCid.codec] + + if (!codec) { + throw Boom.badRequest(`Missing IPLD format "${lastCid.codec}"`) + } + + const resolve = promisify(codec.resolver.resolve) + const res = await resolve(block.data, lastRemainderPath) + + if (!res.remainderPath) { + break + } + + lastRemainderPath = res.remainderPath + + if (!CID.isCID(res.value)) { + break + } + + lastCid = res.value + } + + return h.response({ + Cid: { + '/': lastCid.toBaseEncodedString(cidBase) + }, + RemPath: lastRemainderPath + }) + } catch (err) { + throw Boom.boomify(err) + } + } +} diff --git a/src/http/api/resources/index.js b/src/http/api/resources/index.js index a54aa0d4f3..3f32cccdf5 100644 --- a/src/http/api/resources/index.js +++ b/src/http/api/resources/index.js @@ -15,6 +15,7 @@ exports.bitswap = require('./bitswap') exports.file = require('./file') exports.filesRegular = require('./files-regular') exports.pubsub = require('./pubsub') +exports.dag = require('./dag') exports.dns = require('./dns') exports.key = require('./key') exports.stats = require('./stats') diff --git a/src/http/api/routes/dag.js b/src/http/api/routes/dag.js new file mode 100644 index 0000000000..dd3829b60f --- /dev/null +++ b/src/http/api/routes/dag.js @@ -0,0 +1,43 @@ +'use strict' + +const resources = require('../resources') + +module.exports = [ + { + method: 'POST', + path: '/api/v0/dag/get', + options: { + pre: [ + { method: resources.dag.get.parseArgs, assign: 'args' } + ], + validate: resources.dag.get.validate + }, + handler: resources.dag.get.handler + }, + { + method: 'POST', + path: '/api/v0/dag/put', + options: { + payload: { + parse: false, + output: 'stream' + }, + pre: [ + { method: resources.dag.put.parseArgs, assign: 'args' } + ], + validate: resources.dag.put.validate + }, + handler: resources.dag.put.handler + }, + { + method: 'POST', + path: '/api/v0/dag/resolve', + options: { + pre: [ + { method: resources.dag.resolve.parseArgs, assign: 'args' } + ], + validate: resources.dag.resolve.validate + }, + handler: resources.dag.resolve.handler + } +] diff --git a/src/http/api/routes/index.js b/src/http/api/routes/index.js index 0f459aaf8b..48fcfff3f8 100644 --- a/src/http/api/routes/index.js +++ b/src/http/api/routes/index.js @@ -19,6 +19,7 @@ module.exports = [ ...require('./pubsub'), require('./debug'), ...require('./webui'), + ...require('./dag'), require('./dns'), ...require('./key'), ...require('./stats'), diff --git a/test/http-api/dag.js b/test/http-api/dag.js new file mode 100644 index 0000000000..9217c3185a --- /dev/null +++ b/test/http-api/dag.js @@ -0,0 +1,111 @@ +/* eslint max-nested-callbacks: ["error", 8] */ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const path = require('path') +const expect = chai.expect +chai.use(dirtyChai) +const DaemonFactory = require('ipfsd-ctl') +const df = DaemonFactory.create({ + exec: path.resolve(__dirname, '..', '..', 'src', 'cli', 'bin.js') +}) + +describe('dag endpoint', () => { + let ipfs = null + let ipfsd = null + + before(function (done) { + this.timeout(20 * 1000) + + df.spawn({ + initOptions: { + bits: 1024 + }, + config: { + Bootstrap: [], + Discovery: { + MDNS: { + Enabled: false + }, + webRTCStar: { + Enabled: false + } + } + } + }, (err, _ipfsd) => { + if (err) { + console.error(err) + } + + expect(err).to.not.exist() + ipfsd = _ipfsd + ipfs = ipfsd.api + done() + }) + }) + + after((done) => ipfsd.stop(done)) + + it('returns error for request without argument', (done) => { + ipfs.dag.get(null, (err, result) => { + expect(err.message).to.include("invalid 'ipfs ref' path") + done() + }) + }) + + it('returns error for request with invalid argument', (done) => { + ipfs.dag.get('invalid', { enc: 'base58' }, (err, result) => { + expect(err.message).to.include("invalid 'ipfs ref' path") + done() + }) + }) + + it('returns value', (done) => { + ipfs.dag.get('QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n', (err, result) => { + expect(err).to.not.exist() + expect(result.value).to.be.ok() + expect(result.value.links).to.be.empty() + expect(result.value.data).to.be.empty() + + done() + }) + }) + + it('returns value with a path as part of the cid', (done) => { + ipfs.dag.put({ + foo: 'bar' + }, { + format: 'dag-cbor', + hash: 'sha2-256' + }, (err, cid) => { + expect(err).to.not.exist() + + ipfs.dag.get(`${cid.toBaseEncodedString()}/foo`, (err, result) => { + expect(err).to.not.exist() + expect(result.value).to.equal('bar') + + done() + }) + }) + }) + + it('returns value with a path as a separate argument', (done) => { + ipfs.dag.put({ + foo: 'bar' + }, { + format: 'dag-cbor', + hash: 'sha2-256' + }, (err, cid) => { + expect(err).to.not.exist() + + ipfs.dag.get(cid, 'foo', (err, result) => { + expect(err).to.not.exist() + expect(result.value).to.equal('bar') + + done() + }) + }) + }) +}) diff --git a/test/http-api/index.js b/test/http-api/index.js index a4cceb57a9..43079247f3 100644 --- a/test/http-api/index.js +++ b/test/http-api/index.js @@ -3,6 +3,7 @@ require('./block') require('./bootstrap') require('./config') +require('./dag') require('./dns') require('./id') require('./routes') diff --git a/test/http-api/interface.js b/test/http-api/interface.js index 9529fdece2..7169a6acc0 100644 --- a/test/http-api/interface.js +++ b/test/http-api/interface.js @@ -17,9 +17,7 @@ describe('interface-ipfs-core over ipfs-http-client tests', () => { tests.config(defaultCommonFactory) - tests.dag(defaultCommonFactory, { - skip: { reason: 'TODO: DAG HTTP endpoints not implemented in js-ipfs yet!' } - }) + tests.dag(defaultCommonFactory) tests.dht(CommonFactory.create({ spawnOptions: {