diff --git a/src/interfaces/playground.js b/src/interfaces/playground.js index 95250361a..038683cff 100644 --- a/src/interfaces/playground.js +++ b/src/interfaces/playground.js @@ -20,7 +20,7 @@ const Parser0183 = require('@signalk/nmea0183-signalk') const N2kMapper = require('@signalk/n2k-signalk').N2kMapper -const { putPath } = require('../put') +const { putPath, deletePath } = require('../put') const { isN2KString, FromPgn, @@ -67,7 +67,7 @@ module.exports = function (app) { if (first.pgn) { type = 'n2k-json' - } else if (first.updates || first.put) { + } else if (first.updates || first.put || first.delete) { type = 'signalk' } else { return { error: 'unknown JSON format' } @@ -139,6 +139,26 @@ module.exports = function (app) { ) }) ) + } else if (msg.delete) { + puts.push( + new Promise((resolve) => { + setTimeout(() => { + resolve('Timed out waiting for put result') + }, 5000) + deletePath( + app, + msg.context, + msg.delete.path, + req, + msg.requestId, + (reply) => { + if (reply.state !== 'PENDING') { + resolve(reply) + } + } + ) + }) + ) } else { app.handleMessage('input-test', msg) } diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js index 93d4a4935..ed02b3c40 100644 --- a/src/interfaces/ws.js +++ b/src/interfaces/ws.js @@ -23,7 +23,7 @@ const { updateRequest, queryRequest } = require('../requestResponse') -const { putPath } = require('../put') +const { putPath, deletePath } = require('../put') import { createDebug } from '../debug' import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken' import { startEvents, startServerEvents } from '../events' @@ -235,6 +235,10 @@ module.exports = function (app) { processPutRequest(spark, msg) } + if (msg.delete) { + processDeleteRequest(spark, msg) + } + if (msg.requestId && msg.query) { processReuestQuery(spark, msg) } @@ -348,6 +352,28 @@ module.exports = function (app) { }) } + function processDeleteRequest(spark, msg) { + deletePath( + app, + msg.context, + msg.delete.path, + spark.request, + msg.requestId, + (reply) => { + debug('sending put update %j', reply) + spark.write(reply) + } + ).catch((err) => { + console.error(err) + spark.write({ + requestId: msg.requestId, + state: 'COMPLETED', + statusCode: 502, + message: err.message + }) + }) + } + function processAccessRequest(spark, msg) { if (spark.skPendingAccessRequest) { spark.write({ diff --git a/src/put.js b/src/put.js index c7e3f9e01..33c473045 100644 --- a/src/put.js +++ b/src/put.js @@ -3,6 +3,7 @@ import { createDebug } from './debug' const debug = createDebug('signalk-server:put') const { createRequest, updateRequest } = require('./requestResponse') const skConfig = require('./config/config') +const { getMetadata } = require('@signalk/signalk-schema') const pathPrefix = '/signalk' const versionPrefix = '/v1' @@ -24,13 +25,39 @@ const Result = { } const actionHandlers = {} -let putMetaHandler +let putMetaHandler, deleteMetaHandler module.exports = { start: function (app) { app.registerActionHandler = registerActionHandler app.deRegisterActionHandler = deRegisterActionHandler + app.delete(apiPathPrefix + '*', function (req, res) { + let path = String(req.path).replace(apiPathPrefix, '') + + path = path.replace(/\/$/, '').replace(/\//g, '.') + + const parts = path.length > 0 ? path.split('.') : [] + + if (parts.length < 3) { + res.status(400).send('invalid path') + return + } + + const context = `${parts[0]}.${parts[1]}` + const skpath = parts.slice(2).join('.') + + deletePath(app, context, skpath, req) + .then((reply) => { + res.status(reply.statusCode) + res.json(reply) + }) + .catch((err) => { + console.error(err) + res.status(500).send(err.message) + }) + }) + app.put(apiPathPrefix + '*', function (req, res) { let path = String(req.path).replace(apiPathPrefix, '') @@ -82,7 +109,22 @@ module.exports = { } app.config.baseDeltaEditor.setMeta(context, metaPath, metaValue) - skConfig.sendBaseDeltas(app) + + let full_meta = getMetadata('vessels.self.' + metaPath) + + app.handleMessage('defaults', { + context: 'vessels.self', + updates: [ + { + meta: [ + { + path: metaPath, + value: { ...full_meta, ...metaValue } + } + ] + } + ] + }) if (app.config.hasOldDefaults) { let data @@ -122,10 +164,169 @@ module.exports = { return { state: 'PENDING' } } + + deleteMetaHandler = (context, path, cb) => { + let parts = path.split('.') + let metaPath = path + let full_meta + + //fixme, make sure meta path exists... + + if (parts[parts.length - 1] !== 'meta') { + let name = parts[parts.length - 1] + metaPath = parts.slice(0, parts.length - 2).join('.') + + let metaValue = { + ...app.config.baseDeltaEditor.getMeta(context, metaPath) + } + + if (typeof metaValue[name] === 'undefined') { + return { state: 'COMPLETED', statusCode: 404 } + } + + delete metaValue[name] + + full_meta = getMetadata('vessels.self.' + metaPath) + delete full_meta[name] + + app.config.baseDeltaEditor.setMeta(context, metaPath, metaValue) + + if (Object.keys(metaValue).length == 0) { + app.config.baseDeltaEditor.removeMeta(context, metaPath) + } + } else { + metaPath = parts.slice(0, parts.length - 1).join('.') + + full_meta = getMetadata('vessels.self.' + metaPath) + let metaValue = app.config.baseDeltaEditor.getMeta(context, metaPath) + + if (!metaValue) { + return { state: 'COMPLETED', statusCode: 404 } + } + + Object.keys(metaValue).forEach((key) => { + delete full_meta[key] + }) + + app.config.baseDeltaEditor.removeMeta(context, metaPath) + } + + app.handleMessage('defaults', { + context: 'vessels.self', + updates: [ + { + meta: [ + { + path: metaPath, + value: full_meta + } + ] + } + ] + }) + + skConfig + .writeBaseDeltasFile(app, app.config.baseDeltas) + .then(() => { + cb({ state: 'COMPLETED', statusCode: 200 }) + }) + .catch(() => { + cb({ + state: 'COMPLETED', + statusCode: 502, + message: 'Unable to save to defaults file' + }) + }) + + return { state: 'PENDING' } + } }, registerActionHandler: registerActionHandler, - putPath: putPath + putPath: putPath, + deletePath +} + +function deletePath(app, contextParam, path, req, requestId, updateCb) { + const context = contextParam || 'vessels.self' + debug('received delete %s %s', context, path) + return new Promise((resolve, reject) => { + createRequest( + app, + 'delete', + { + context: context, + requestId: requestId, + delete: { path: path } + }, + req && req.skPrincipal ? req.skPrincipal.identifier : undefined, + null, + updateCb + ) + .then((request) => { + if ( + req && + app.securityStrategy.shouldAllowPut(req, context, null, path) === + false + ) { + updateRequest(request.requestId, 'COMPLETED', { statusCode: 403 }) + .then(resolve) + .catch(reject) + return + } + + const parts = path.split('.') + let handler + + if ( + (parts.length > 1 && parts[parts.length - 1] === 'meta') || + (parts.length > 1 && parts[parts.length - 2] === 'meta') + ) { + handler = deleteMetaHandler + } + + if (handler) { + const actionResult = handler(context, path, (reply) => { + debug('got result: %j', reply) + updateRequest(request.requestId, reply.state, reply) + .then(() => undefined) + .catch((err) => { + console.error(err) + }) + }) + + Promise.resolve(actionResult) + .then((result) => { + debug('got result: %j', result) + updateRequest(request.requestId, result.state, result) + .then((reply) => { + if (reply.state === 'PENDING') { + // backwards compatibility + reply.action = { href: reply.href } + } + resolve(reply) + }) + .catch(reject) + }) + .catch((err) => { + updateRequest(request.requestId, 'COMPLETED', { + statusCode: 500, + message: err.message + }) + .then(resolve) + .catch(reject) + }) + } else { + updateRequest(request.requestId, 'COMPLETED', { + statusCode: 405, + message: `DELTETE not supported for ${path}` + }) + .then(resolve) + .catch(reject) + } + }) + .catch(reject) + }) } function putPath(app, contextParam, path, body, req, requestId, updateCb) { diff --git a/test/delete.js b/test/delete.js new file mode 100644 index 000000000..5a25c15c9 --- /dev/null +++ b/test/delete.js @@ -0,0 +1,131 @@ +const chai = require('chai') +chai.Should() +chai.use(require('chai-things')) + +import { startServer } from './ts-servertestutilities' + +const fetch = require('node-fetch') + +describe('Delete Requests', () => { + let doStop, doSendDelta, theHost, doSelfPut, doGet, doCreateWsPromiser + + before(async () => { + const { + createWsPromiser, + selfPutV1, + sendDelta, + stop, + host, + getV1 + } = await startServer() + doStop = stop + doSendDelta = sendDelta + theHost = host + doSelfPut = selfPutV1 + doGet = getV1 + doCreateWsPromiser = createWsPromiser + }) + + after(async function () { + await doStop() + }) + + it('HTTP delete to unhandled path fails', async function () { + await doSendDelta('navigation.logTrip', 43374) + + const result = await fetch( + `${theHost}/signalk/v1/api/vessels/self/navigation/logTrip`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + } + ) + + result.status.should.equal(405) + }) + + it('HTTP successful DELETE', async function () { + let result = await doSelfPut('navigation/logTrip/meta/displayName', { + value: 'My Log Trip' + }) + + result.status.should.equal(202) + + result = await doGet('/vessels/self/navigation/logTrip/meta/displayName') + result.status.should.equal(200) + let name = await result.json() + name.should.equal('My Log Trip') + + result = await fetch( + `${theHost}/signalk/v1/api/vessels/self/navigation/logTrip/meta/displayName`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + } + ) + + result.status.should.equal(202) + + result = await doGet('/vessels/self/navigation/logTrip/meta/displayName') + result.status.should.equal(404) + }) + + it('WS delete to unhandled path fails', async function () { + const ws = doCreateWsPromiser() + + let msg = await ws.nextMsg() + + ws.send({ + context: 'vessels.self', + delete: { + path: 'navigation.logTrip' + } + }) + + msg = await ws.nextMsg() + msg.should.not.equal('timeout') + const response = JSON.parse(msg) + response.should.have.property('statusCode') + response.statusCode.should.equal(405) + }) + + + it('WS successful DELETE', async function () { + let result = await doSelfPut('navigation/logTrip/meta/displayName', { + value: 'My Log Trip' + }) + + result.status.should.equal(202) + + result = await doGet('/vessels/self/navigation/logTrip/meta/displayName') + result.status.should.equal(200) + let name = await result.json() + name.should.equal('My Log Trip') + + const ws = doCreateWsPromiser() + + let msg = await ws.nextMsg() + + ws.send({ + context: 'vessels.self', + delete: { + path: 'navigation.logTrip.meta.displayName' + } + }) + + await ws.nextMsg() //skip the meta delta + msg = await ws.nextMsg() + console.log(msg) + msg.should.not.equal('timeout') + const response = JSON.parse(msg) + response.should.have.property('statusCode') + response.statusCode.should.equal(200) + + result = await doGet('/vessels/self/navigation/logTrip/meta/displayName') + result.status.should.equal(404) + }) +}) diff --git a/test/ts-servertestutilities.ts b/test/ts-servertestutilities.ts index 4bae5218a..8246e4210 100644 --- a/test/ts-servertestutilities.ts +++ b/test/ts-servertestutilities.ts @@ -25,6 +25,7 @@ export const startServer = async () => { const host = 'http://localhost:' + port const sendDeltaUrl = host + '/signalk/v1/api/_test/delta' const api = host + '/signalk/v2/api' + const v1Api = host + '/signalk/v1/api' await emptyConfigDirectory() const server = await startServerP(port, false, { @@ -48,11 +49,18 @@ export const startServer = async () => { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }), + selfPutV1: (path: string, body: object) => + fetch(`${v1Api}/vessels/self/${path}`, { + method: 'PUT', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + }), selfDelete: (path: string) => fetch(`${api}/vessels/self/${path}`, { method: 'DELETE' }), get: (path: string) => fetch(`${api}${path}`), + getV1: (path: string) => fetch(`${v1Api}${path}`), post: (path: string, body: object) => fetch(`${api}${path}`, { method: 'POST', @@ -67,6 +75,9 @@ export const startServer = async () => { }), selfGetJson: (path: string) => fetch(`${api}/vessels/self/${path}`).then(r => r.json()), + selfGetJsonV1: (path: string) => + fetch(`${v1Api}/vessels/self/${path}`).then(r => r.json()), + host, sendDelta: (path: string, value: any) => sendDelta( {