├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── eval.test.js └── sql.test.js ├── broker.js ├── eval.js ├── index.js ├── package.json └── sql.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Tradle Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-iot-local 2 | 3 | AWS Iot lifecycle and regular topic subscription events 4 | 5 | ## Prerequisites 6 | * serverless@1.x 7 | * redis 8 | 9 | ## Install 10 | 11 | 1) `npm install --save serverless-iot-local` 12 | 13 | 2) In `serverless.yml` add `serverless-iot-local` to plugins: 14 | 15 | ```yaml 16 | plugins: 17 | - serverless-iot-local 18 | ``` 19 | 20 | ## Usage 21 | 1. Start redis: 22 | `redis-server` 23 | 24 | 2. If you're using [serverless-offline](https://github.com/dherault/serverless-offline), you can run: 25 | 26 | `sls offline start` 27 | 28 | Otherwise run: 29 | 30 | `sls iot start` 31 | 32 | CLI options are optional: 33 | 34 | ``` 35 | --port -p Port to listen on. Default: 1883 36 | --httpPort -h Port for WebSocket connections. Default: 1884 37 | --noStart -n Prevent Iot broker (Mosca MQTT brorker) from being started (if you already have one) 38 | --skipCacheValidation -c Tells the plugin to skip require cache invalidation. A script reloading tool like Nodemon might then be needed (same as serverless-offline) 39 | ``` 40 | 41 | The above options can be added to serverless.yml to set default configuration, e.g.: 42 | 43 | ```yml 44 | custom: 45 | serverless-iot-local: 46 | start: 47 | port: 1884 48 | # Uncomment only if you already have an MQTT server running locally 49 | # noStart: true 50 | redis: 51 | host: 'localhost' 52 | port: 6379 53 | db: 12 54 | ``` 55 | 56 | ### Using with serverless-offline plugin 57 | 58 | Place `serverless-iot-local` above `serverless-offline` 59 | 60 | ```yaml 61 | plugins: 62 | - serverless-iot-local 63 | - serverless-offline 64 | ``` 65 | 66 | ## Todo 67 | 68 | - Improve support of AWS Iot SQL syntax 69 | 70 | ## License 71 | [MIT](LICENSE) 72 | -------------------------------------------------------------------------------- /__tests__/eval.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const sinon = require('sinon') 3 | const evalInContext = require('../eval') 4 | 5 | test('evalInContext - evals global variable', (t) => { 6 | global.property = 'property' 7 | t.equal(evalInContext('property', {}), 'property') 8 | t.end() 9 | }) 10 | 11 | test('evalInContext - evals function in context', (t) => { 12 | const clientid = sinon.stub().returns('test') 13 | t.equal(evalInContext('clientid()', { clientid }), 'test') 14 | t.end() 15 | }) 16 | 17 | test('throws error if variable does not exist', (t) => { 18 | t.throws((() => evalInContext('notHere', {}))) 19 | t.end() 20 | }) 21 | -------------------------------------------------------------------------------- /__tests__/sql.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const sinon = require('sinon') 3 | const { parseSelect, applySelect } = require('../sql.js') 4 | 5 | test('parseSelect - parses simple SQL correctly', (t) => { 6 | const subject = "SELECT * FROM 'topic'" 7 | const results = parseSelect(subject) 8 | t.deepEqual(results.select, [{ field: '*', alias: undefined }]) 9 | t.equal(results.topic, 'topic') 10 | t.equal(results.where, undefined) 11 | t.end() 12 | }) 13 | 14 | test('parseSelect - parses lowercase simple SQL correctly', (t) => { 15 | const subject = "select * from 'topic'" 16 | const results = parseSelect(subject) 17 | t.deepEqual(results.select, [{ field: '*', alias: undefined }]) 18 | t.equal(results.topic, 'topic') 19 | t.equal(results.where, undefined) 20 | t.end() 21 | }) 22 | 23 | test('parseSelect - parses where clause correctly', (t) => { 24 | const subject = "SELECT * FROM 'topic' WHERE name='Bob'" 25 | const results = parseSelect(subject) 26 | t.deepEqual(results.select, [{ field: '*', alias: undefined }]) 27 | t.equal(results.topic, 'topic') 28 | t.equal(results.where, "name='Bob'") 29 | t.end() 30 | }) 31 | 32 | test('parseSelect - parses multiple SELECT properties correctly', (t) => { 33 | const subject = "SELECT name, age, maleOrFemale AS gender FROM 'topic'" 34 | const results = parseSelect(subject) 35 | t.deepEqual(results.select, [ 36 | { field: 'name', alias: undefined}, 37 | { field: 'age', alias: undefined }, 38 | { field: 'maleOrFemale', alias: 'gender'} 39 | ]) 40 | t.end() 41 | }) 42 | 43 | test('applySelect - Simple select with buffered string handled correctly', (t) => { 44 | const select = [{ field: '*', alias: undefined }] 45 | const payload = Buffer.from(JSON.stringify({name: 'Bob'}), 'utf8') 46 | const context = {} 47 | const event = applySelect({ select, payload, context }) 48 | t.deepEqual(event, { name: 'Bob' }) 49 | t.end() 50 | }) 51 | 52 | test('applySelect - Simple select with non-JSON handled correctly', (t) => { 53 | const select = [{ field: '*', alias: undefined }] 54 | const payload = 'Bob' 55 | const context = {} 56 | const event = applySelect({ select, payload, context }) 57 | t.equal(event, 'Bob') 58 | t.end() 59 | }) 60 | 61 | test('applySelect - Aliased wildcard with non-JSON handled correctly', (t) => { 62 | const select = [{ field: '*', alias: 'name' }] 63 | const payload = 'Bob' 64 | const context = {} 65 | const event = applySelect({ select, payload, context }) 66 | t.deepEqual(event, { 'name': 'Bob'}) 67 | t.end() 68 | }) 69 | 70 | test('applySelect - Unaliased wildcard plus function results in flattened output', (t) => { 71 | const select = [ 72 | { field: '*', alias: undefined }, 73 | { field: 'clientid()', alias: undefined } 74 | ] 75 | const clientIdFunc = sinon.stub().returns(undefined); 76 | const payload = Buffer.from(JSON.stringify({name: 'Bob'}), 'utf8') 77 | const context = { clientid: clientIdFunc } 78 | const event = applySelect({ select, payload, context }) 79 | t.ok(clientIdFunc.calledOnce) 80 | t.deepEqual(event, { name: 'Bob', 'clientid()': undefined }) 81 | t.end() 82 | }) 83 | 84 | test('applySelect - Aliased wildcard plus function results in nested output', (t) => { 85 | const select = [ 86 | { field: '*', alias: 'message' }, 87 | { field: 'clientid()', alias: undefined } 88 | ] 89 | const clientIdFunc = sinon.stub().returns(undefined); 90 | const payload = Buffer.from(JSON.stringify({name: 'Bob'}), 'utf8') 91 | const context = { clientid: clientIdFunc } 92 | const event = applySelect({ select, payload, context }) 93 | t.ok(clientIdFunc.calledOnce) 94 | t.deepEqual(event, { message: { name: 'Bob' }, 'clientid()': undefined }) 95 | t.end() 96 | }) 97 | 98 | test('applySelect - Function results are appeneded to output', (t) => { 99 | const select = [ 100 | { field: '*', alias: 'message' }, 101 | { field: 'clientid()', alias: 'theClientId' } 102 | ] 103 | const clientIdFunc = sinon.stub().returns('12345') 104 | const payload = Buffer.from(JSON.stringify({name: 'Bob'}), 'utf8') 105 | const context = { clientid: clientIdFunc } 106 | const event = applySelect({ select, payload, context }) 107 | t.ok(clientIdFunc.calledOnce) 108 | t.deepEqual(event, { message: { name: 'Bob' }, 'theClientId': '12345' }) 109 | t.end() 110 | }) 111 | -------------------------------------------------------------------------------- /broker.js: -------------------------------------------------------------------------------- 1 | const mosca = require('mosca') 2 | 3 | // fired when the mqtt server is ready 4 | function setup() { 5 | console.log('Mosca server is up and running') 6 | } 7 | 8 | function createAWSLifecycleEvent ({ type, clientId, topics }) { 9 | // http://docs.aws.amazon.com/iot/latest/developerguide/life-cycle-events.html#subscribe-unsubscribe-events 10 | const event = { 11 | clientId, 12 | timestamp: Date.now(), 13 | eventType: type, 14 | sessionIdentifier: '00000000-0000-0000-0000-000000000000', 15 | principalIdentifier: '000000000000/ABCDEFGHIJKLMNOPQRSTU:some-user/ABCDEFGHIJKLMNOPQRSTU:some-user' 16 | } 17 | 18 | if (topics) { 19 | event.topics = topics 20 | } 21 | 22 | return event 23 | } 24 | 25 | /** 26 | * https://github.com/aws/aws-sdk-js/blob/master/clients/iot.d.ts#L349 27 | * 28 | * @param {Object} opts Module options 29 | * @param {Object} moscaOpts Mosca options 30 | */ 31 | function createBroker (ascoltatore, moscaOpts) { 32 | const moscaSettings = { 33 | // port: 1883, 34 | backend: ascoltatore, 35 | persistence: { 36 | factory: mosca.persistence.Redis 37 | } 38 | } 39 | 40 | moscaOpts = Object.assign({}, moscaSettings, moscaOpts) 41 | const server = new mosca.Server(moscaOpts) 42 | server.on('ready', setup) 43 | 44 | // fired when a message is received 45 | server.on('published', function (packet, client) { 46 | const presence = packet.topic.match(/^\$SYS\/.*\/(new|disconnect)\/clients$/) 47 | if (presence) { 48 | const clientId = packet.payload 49 | const type = presence[1] === 'new' ? 'connected' : 'disconnected' 50 | server.publish({ 51 | topic: `$aws/events/presence/${type}/${clientId}`, 52 | payload: JSON.stringify(createAWSLifecycleEvent({ 53 | type, 54 | clientId 55 | })) 56 | }) 57 | } 58 | 59 | const subscription = packet.topic.match(/^\$SYS\/.*\/new\/(subscribes|unsubscribes)$/) 60 | if (subscription) { 61 | const type = subscription[1] === 'subscribes' ? 'subscribed' : 'unsubscribed' 62 | const { clientId, topic } = JSON.parse(packet.payload) 63 | server.publish({ 64 | topic: `$aws/events/subscriptions/${type}/${clientId}`, 65 | payload: JSON.stringify(createAWSLifecycleEvent({ 66 | type, 67 | clientId, 68 | topics: [topic] 69 | })) 70 | }) 71 | } 72 | }) 73 | 74 | return server 75 | } 76 | 77 | module.exports = createBroker 78 | -------------------------------------------------------------------------------- /eval.js: -------------------------------------------------------------------------------- 1 | // TODO: trim(), ltrim(), etc 2 | 3 | const evalInContext = (js, context) => { 4 | const { clientid, topic, principal } = context 5 | try { 6 | return eval(js) 7 | } catch (err) { 8 | debugger 9 | console.log(`failed to evaluate: ${js}`) 10 | throw err 11 | } 12 | } 13 | 14 | const encode = (data, encoding) => { 15 | if (encoding !== 'base64') { 16 | throw new Error('AWS Iot SQL encode() function only supports base64 as an encoding') 17 | } 18 | 19 | return data.toString(encoding) 20 | } 21 | 22 | module.exports = evalInContext 23 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const _ = require('lodash') 3 | const mqtt = require('mqtt') 4 | const mqttMatch = require('mqtt-match') 5 | const realAWS = require('aws-sdk') 6 | const AWS = require('aws-sdk-mock') 7 | AWS.setSDK(path.resolve('node_modules/aws-sdk')) 8 | const extend = require('xtend') 9 | const IP = require('ip') 10 | const redis = require('redis') 11 | const SQL = require('./sql') 12 | const evalInContext = require('./eval') 13 | const createMQTTBroker = require('./broker') 14 | // TODO: send PR to serverless-offline to export this 15 | const functionHelper = require('serverless-offline/src/functionHelper') 16 | const createLambdaContext = require('serverless-offline/src/createLambdaContext') 17 | const VERBOSE = typeof process.env.SLS_DEBUG !== 'undefined' 18 | const defaultOpts = { 19 | host: 'localhost', 20 | location: '.', 21 | port: 1883, 22 | httpPort: 1884, 23 | noStart: false, 24 | skipCacheInvalidation: false 25 | } 26 | 27 | const ascoltatoreOpts = { 28 | type: 'redis', 29 | redis, 30 | host: 'localhost', 31 | port: 6379, 32 | db: 12, 33 | return_buffers: true // to handle binary payloads 34 | } 35 | 36 | class ServerlessIotLocal { 37 | constructor(serverless, options) { 38 | this.serverless = serverless 39 | this.log = serverless.cli.log.bind(serverless.cli) 40 | this.service = serverless.service 41 | this.options = options 42 | this.provider = this.serverless.getProvider('aws') 43 | this.mqttBroker = null 44 | this.requests = {} 45 | 46 | this.commands = { 47 | iot: { 48 | commands: { 49 | start: { 50 | usage: 'Start local Iot broker.', 51 | lifecycleEvents: ['startHandler'], 52 | options: { 53 | host: { 54 | usage: 'host name to listen on. Default: localhost', 55 | // match serverless-offline option shortcuts 56 | shortcut: 'o' 57 | }, 58 | port: { 59 | usage: 'MQTT port to listen on. Default: 1883', 60 | shortcut: 'p' 61 | }, 62 | httpPort: { 63 | usage: 'http port for client connections over WebSockets. Default: 1884', 64 | shortcut: 'h' 65 | }, 66 | noStart: { 67 | shortcut: 'n', 68 | usage: 'Do not start local MQTT broker (in case it is already running)', 69 | }, 70 | skipCacheInvalidation: { 71 | usage: 'Tells the plugin to skip require cache invalidation. A script reloading tool like Nodemon might then be needed', 72 | shortcut: 'c', 73 | }, 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | this.hooks = { 81 | 'iot:start:startHandler': this.startHandler.bind(this), 82 | 'before:offline:start:init': this.startHandler.bind(this), 83 | 'before:offline:start': this.startHandler.bind(this), 84 | 'before:offline:start:end': this.endHandler.bind(this), 85 | } 86 | } 87 | 88 | debug() { 89 | if (VERBOSE) { 90 | this.log.apply(this, arguments) 91 | } 92 | } 93 | 94 | startHandler() { 95 | this.originalEnvironment = _.extend({ IS_OFFLINE: true }, process.env) 96 | 97 | const custom = this.service.custom || {} 98 | const inheritedFromServerlessOffline = _.pick(custom['serverless-offline'] || {}, ['skipCacheInvalidation']) 99 | 100 | this.options = _.merge( 101 | {}, 102 | defaultOpts, 103 | inheritedFromServerlessOffline, 104 | custom['serverless-iot-local'], 105 | this.options 106 | ) 107 | 108 | if (!this.options.noStart) { 109 | this._createMQTTBroker() 110 | } 111 | 112 | this._createMQTTClient() 113 | } 114 | 115 | endHandler() { 116 | this.log('Stopping Iot broker') 117 | this.mqttBroker.close() 118 | } 119 | 120 | _createMQTTBroker() { 121 | const { host, port, httpPort } = this.options 122 | 123 | const mosca = { 124 | host, 125 | port, 126 | http: { 127 | host, 128 | port: httpPort, 129 | bundle: true 130 | } 131 | } 132 | 133 | // For now we'll only support redis backend. 134 | const redisConfigOpts = this.options.redis; 135 | 136 | const ascoltatore = _.merge({}, ascoltatoreOpts, redisConfigOpts) 137 | 138 | this.mqttBroker = createMQTTBroker(ascoltatore, mosca) 139 | 140 | const endpointAddress = `${IP.address()}:${httpPort}` 141 | 142 | // prime AWS IotData import 143 | // this is necessary for below mock to work 144 | // eslint-disable-next-line no-unused-vars 145 | const notUsed = new realAWS.IotData({ 146 | endpoint: endpointAddress, 147 | region: 'us-east-1' 148 | }) 149 | 150 | AWS.mock('IotData', 'publish', (params, callback) => { 151 | const { topic, payload } = params 152 | this.mqttBroker.publish({ topic, payload }, callback) 153 | }) 154 | 155 | AWS.mock('Iot', 'describeEndpoint', (params, callback) => { 156 | process.nextTick(() => { 157 | // Parameter params is optional. 158 | (callback || params)(null, { endpointAddress }) 159 | }) 160 | }) 161 | 162 | this.log(`Iot broker listening on ports: ${port} (mqtt) and ${httpPort} (http)`) 163 | } 164 | 165 | _getServerlessOfflinePort() { 166 | // hackeroni! 167 | const offline = this.serverless.pluginManager.plugins.find( 168 | plugin => plugin.commands && plugin.commands.offline 169 | ) 170 | 171 | if (offline) { 172 | return offline.options.httpPort || offline.options.port 173 | } 174 | } 175 | 176 | _createMQTTClient() { 177 | const { port, httpPort, location } = this.options 178 | const topicsToFunctionsMap = {} 179 | const { runtime } = this.service.provider 180 | const stackName = this.provider.naming.getStackName() 181 | Object.keys(this.service.functions).forEach(key => { 182 | const fun = this._getFunction(key) 183 | const funName = key 184 | const servicePath = path.join(this.serverless.config.servicePath, location) 185 | const funOptions = functionHelper.getFunctionOptions(fun, key, servicePath) 186 | this.debug(`funOptions ${JSON.stringify(funOptions, null, 2)} `) 187 | 188 | if (!fun.environment) { 189 | fun.environment = {} 190 | } 191 | 192 | fun.environment.AWS_LAMBDA_FUNCTION_NAME = `${this.service.service}-${this.service.provider.stage}-${funName}` 193 | 194 | this.debug('') 195 | this.debug(funName, 'runtime', runtime, funOptions.babelOptions || '') 196 | this.debug(`events for ${funName}:`) 197 | 198 | if (!(fun.events && fun.events.length)) { 199 | this.debug('(none)') 200 | return 201 | } 202 | 203 | fun.events.forEach(event => { 204 | if (!event.iot) return this.debug('(none)') 205 | 206 | const { iot } = event 207 | const { sql } = iot 208 | // hack 209 | // assumes SELECT ... topic() as topic 210 | const parsed = SQL.parseSelect({ 211 | sql, 212 | stackName, 213 | }) 214 | 215 | const topicMatcher = parsed.topic 216 | if (!topicsToFunctionsMap[topicMatcher]) { 217 | topicsToFunctionsMap[topicMatcher] = [] 218 | } 219 | 220 | this.debug('topicMatcher') 221 | topicsToFunctionsMap[topicMatcher].push({ 222 | fn: fun, 223 | name: key, 224 | options: funOptions, 225 | select: parsed.select 226 | }) 227 | }) 228 | }) 229 | 230 | const client = mqtt.connect(`ws://localhost:${httpPort}/mqqt`) 231 | client.on('error', console.error) 232 | 233 | let connectMonitor 234 | const startMonitor = () => { 235 | clearInterval(connectMonitor) 236 | connectMonitor = setInterval(() => { 237 | this.log(`still haven't connected to local Iot broker!`) 238 | }, 5000).unref() 239 | } 240 | 241 | startMonitor() 242 | 243 | client.on('connect', () => { 244 | clearInterval(connectMonitor) 245 | this.log('connected to local Iot broker') 246 | for (let topicMatcher in topicsToFunctionsMap) { 247 | client.subscribe(topicMatcher) 248 | } 249 | }) 250 | 251 | client.on('disconnect', startMonitor) 252 | 253 | client.on('message', (topic, message) => { 254 | const matches = Object.keys(topicsToFunctionsMap) 255 | .filter(topicMatcher => mqttMatch(topicMatcher, topic)) 256 | 257 | if (!matches.length) return 258 | 259 | let clientId 260 | if (/^\$aws\/events/.test(topic)) { 261 | clientId = topic.slice(topic.lastIndexOf('/') + 1) 262 | } else { 263 | // hmm... 264 | } 265 | 266 | const apiGWPort = this._getServerlessOfflinePort() 267 | matches.forEach(topicMatcher => { 268 | let functions = topicsToFunctionsMap[topicMatcher] 269 | functions.forEach(fnInfo => { 270 | const { fn, name, options, select } = fnInfo 271 | const requestId = Math.random().toString().slice(2) 272 | this.requests[requestId] = { done: false } 273 | 274 | const event = SQL.applySelect({ 275 | select, 276 | payload: message, 277 | context: { 278 | topic: () => topic, 279 | clientid: () => clientId, 280 | principal: () => {} 281 | } 282 | }) 283 | 284 | let handler // The lambda function 285 | try { 286 | process.env = _.extend({}, this.service.provider.environment, this.service.functions[name].environment, this.originalEnvironment) 287 | process.env.SERVERLESS_OFFLINE_PORT = apiGWPort 288 | process.env.AWS_LAMBDA_FUNCTION_NAME = this.service.service + '-' + this.service.provider.stage 289 | process.env.AWS_REGION = this.service.provider.region 290 | handler = functionHelper.createHandler(options, this.options) 291 | } catch (err) { 292 | this.log(`Error while loading ${name}: ${err.stack}, ${requestId}`) 293 | return 294 | } 295 | 296 | const lambdaContext = createLambdaContext(fn) 297 | try { 298 | handler(event, lambdaContext, lambdaContext.done) 299 | } catch (error) { 300 | this.log(`Uncaught error in your '${name}' handler: ${error.stack}, ${requestId}`) 301 | } 302 | }) 303 | }) 304 | }) 305 | } 306 | 307 | _getFunction(key) { 308 | const fun = this.service.getFunction(key) 309 | if (!fun.timeout) { 310 | fun.timeout = this.service.provider.timeout 311 | } 312 | 313 | return fun 314 | } 315 | } 316 | 317 | module.exports = ServerlessIotLocal 318 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-iot-local", 3 | "version": "2.1.2", 4 | "description": "local iot events for the Serverless Framework", 5 | "main": "index.js", 6 | "repository": "https://github.com/tradle/serverless-iot-local", 7 | "author": "mvayngrib", 8 | "license": "MIT", 9 | "scripts": { 10 | "test": "tape '__tests__/**/*'", 11 | "coverage": "istanbul cover tape tape '__tests__/**/*'" 12 | }, 13 | "dependencies": { 14 | "aws-sdk-mock": "^1.7.0", 15 | "ip": "^1.1.5", 16 | "lodash": "^4.17.4", 17 | "mosca": "^2.6.0", 18 | "mqtt": "^2.13.1", 19 | "mqtt-match": "^1.0.3", 20 | "redis": "^2.8.0" 21 | }, 22 | "peerDependencies": { 23 | "aws-sdk": "*", 24 | "serverless-offline": "*" 25 | }, 26 | "devDependencies": { 27 | "istanbul": "^0.4.5", 28 | "sinon": "^5.0.3", 29 | "tape": "^4.9.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sql.js: -------------------------------------------------------------------------------- 1 | const evalInContext = require('./eval') 2 | const BASE64_PLACEHOLDER = '*b64' 3 | const SQL_REGEX = /^SELECT (.*)\s+FROM\s+'([^']+)'\s*(?:WHERE\s(.*))?$/i 4 | const SELECT_PART_REGEX = /^(.*?)(?: AS (.*))?$/i 5 | 6 | const parseSelect = ({ sql, stackName }) => { 7 | // if (/\([^)]/.test(sql)) { 8 | // throw new Error(`AWS Iot SQL functions in this sql are not yet supported: ${sql}`) 9 | // } 10 | 11 | if (typeof sql === 'object') { 12 | const sub = sql['Fn::Sub'] 13 | if (!sub) { 14 | throw new Error('expected sql to be a string or have Fn::Sub') 15 | } 16 | 17 | sql = sub.replace(/\$\{AWS::StackName\}/g, stackName) 18 | } 19 | 20 | const [select, topic, where] = sql.match(SQL_REGEX).slice(1) 21 | return { 22 | select: select 23 | // hack 24 | .replace("encode(*, 'base64')", BASE64_PLACEHOLDER) 25 | .split(',') 26 | .map(s => s.trim()) 27 | .map(parseSelectPart), 28 | where, 29 | topic 30 | } 31 | } 32 | 33 | const parseSelectPart = part => { 34 | const [field, alias] = part.match(SELECT_PART_REGEX).slice(1) 35 | return { 36 | field, 37 | alias 38 | } 39 | } 40 | 41 | const brace = new Buffer('{')[0] 42 | const bracket = new Buffer('[')[0] 43 | const doubleQuote = new Buffer('"')[0] 44 | // to avoid stopping here when Stop on Caught Exceptions is on 45 | const maybeParseJSON = val => { 46 | switch (val[0]) { 47 | case brace: 48 | case bracket: 49 | case doubleQuote: 50 | try { 51 | return JSON.parse(val) 52 | } catch (err) {} 53 | } 54 | 55 | return val 56 | } 57 | 58 | const applySelect = ({ select, payload, context }) => { 59 | const event = {} 60 | const json = maybeParseJSON(payload) 61 | if (select.length === 1 && !select[0].alias) { 62 | return json 63 | } 64 | 65 | const payloadReplacement = Buffer.isBuffer(payload) 66 | ? `new Buffer('${payload.toString('base64')}', 'base64')` 67 | : payload 68 | 69 | for (const part of select) { 70 | const { alias, field } = part 71 | const key = alias || field 72 | if (field === '*') { 73 | /* 74 | * If there is an alias for the wildcard selector, we want to include the fields in a nested key. 75 | * SELECT * as message, clientid() from 'topic' 76 | * { message: { fieldOne: 'value', ...}} 77 | * 78 | * Otherwise, we want the fields flat in the resulting event object. 79 | * SELECT *, clientid() from 'topic' 80 | * { fieldOne: 'value', ...} 81 | */ 82 | if(alias) { 83 | event[key] = json 84 | } else { 85 | Object.assign(event, json) 86 | } 87 | continue 88 | } 89 | 90 | const js = field.replace(BASE64_PLACEHOLDER, payloadReplacement) 91 | event[key] = evalInContext(js, context) 92 | } 93 | 94 | return event 95 | } 96 | 97 | module.exports = { 98 | parseSelect, 99 | applySelect, 100 | // parseWhere 101 | } 102 | --------------------------------------------------------------------------------