From 010a069f11a4cf0d1a8186c8329e3a8d05b3ee6d Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 6 Nov 2017 16:51:57 -0500 Subject: [PATCH 01/62] Using conductor support in core --- conductor.js | 73 ++++++++-------------------------------------------- 1 file changed, 11 insertions(+), 62 deletions(-) diff --git a/conductor.js b/conductor.js index f8be541..4c4b0bc 100644 --- a/conductor.js +++ b/conductor.js @@ -35,8 +35,6 @@ function __eval__(__env__, main) { // keep outer namespace clean const main = (() => { - const openwhisk = require('openwhisk') - const request = require('request-promise') const redis = require('redis') // inline redis-promise to keep action code in a single file @@ -65,7 +63,6 @@ const main = (() => { return client } - let wsk // cached openwhisk instance let db // cached redis instance // encode error object @@ -92,35 +89,20 @@ const main = (() => { } } - function poll(activationId, resolve) { // poll for activation record (1s interval) - return wsk.activations.get(activationId).then(resolve, () => setTimeout(() => poll(activationId, resolve), 1000)) - } - // do invocation function invoke(params) { // check parameters if (!isObject(params.$config)) return badRequest('Missing $config parameter of type object') if (typeof params.$config.redis !== 'string') return badRequest('Missing $config.redis parameter of type string') - if (typeof params.$config.notify !== 'undefined' && typeof params.$config.notify !== 'boolean') return badRequest('Type of $config.notify parameter must be Boolean') if (typeof params.$config.expiration !== 'undefined' && typeof params.$config.expiration !== 'number') return badRequest('Type of $config.expiration parameter must be number') - if (typeof params.$activationId !== 'undefined' && typeof params.$activationId !== 'number' && typeof params.$activationId !== 'string') return badRequest('Type of $activationId parameter must be number or string') - if (typeof params.$sessionId !== 'undefined' && typeof params.$sessionId !== 'number' && typeof params.$sessionId !== 'string') return badRequest('Type of $sessionId parameter must be number or string') + if (typeof params.$session !== 'number' && typeof params.$session !== 'string') return badRequest('Type of $session parameter must be number or string') if (typeof params.$invoke !== 'undefined' && !isObject(params.$invoke)) return badRequest('Type of $invoke parameter must be object') - if (typeof params.$blocking !== 'undefined' && typeof params.$blocking !== 'boolean') return badRequest('Type of $blocking parameter must be Boolean') - if (typeof params.$invoke === 'undefined' && typeof params.$sessionId === 'undefined') return badRequest('Missing $invoke or $sessionId parameter') + if (typeof params.$invoke === 'undefined' && typeof params.$resume === 'undefined') return badRequest('Missing $invoke or $resume parameter') // configuration - const notify = params.$config.notify const expiration = params.$config.expiration || (86400 * 7) - const resuming = typeof params.$sessionId !== 'undefined' - const blocking = params.__ow_method || params.$blocking - const session = params.$sessionId || process.env.__OW_ACTIVATION_ID - - // initialize openwhisk instance - if (!wsk) { - wsk = openwhisk({ ignore_certs: true }) - if (!notify) wsk.actions.qs_options.invoke = ['blocking', 'notify', 'cause'] - } + const resuming = params.$resume + const session = params.$session // redis keys const apiKey = process.env.__OW_API_KEY.substring(0, process.env.__OW_API_KEY.indexOf(':')) @@ -142,19 +124,9 @@ const main = (() => { return obj }) } - // retrieve session result from redis - function getSessionResult() { - return db.brpoplpushAsync(sessionResultKey, sessionResultKey, 30).then(result => { - if (typeof result !== 'string') return { $session: session } // timeout - const obj = JSON.parse(result) - if (!isObject(obj)) throw `Result of session ${session} is not a JSON object` - return obj - }) - } // resume suspended session function resume() { - params = params.$result return db.rpushxAsync(sessionTraceKey, process.env.__OW_ACTIVATION_ID) .then(() => getSessionState()) // obtain live session state .then(result => { @@ -162,7 +134,6 @@ const main = (() => { params.$invoke = result.$fsm params.$state = result.$state params.$stack = result.$stack - params.$callee = result.$callee }) } @@ -177,9 +148,9 @@ const main = (() => { } // persist session state to redis - function persist($fsm, $state, $stack, $callee) { + function persist($fsm, $state, $stack) { // ensure using set-if-exists that the session has not been killed - return db.lsetAsync(sessionStateKey, -1, JSON.stringify({ $fsm, $state, $stack, $callee })) + return db.lsetAsync(sessionStateKey, -1, JSON.stringify({ $fsm, $state, $stack })) .catch(() => gone(`Session ${session} has been killed`)) } @@ -205,7 +176,6 @@ const main = (() => { let state = resuming ? params.$state : (params.$state || fsm.Entry) const stack = params.$stack || [] - const callee = params.$callee // wrap params if not a JSON object, branch to error handler if error function inspect() { @@ -226,13 +196,11 @@ const main = (() => { // delete $ params delete params.$config - delete params.$activationId + delete params.$session delete params.$invoke - delete params.$sessionId + delete params.$resume delete params.$state delete params.$stack - delete params.$callee - delete params.$blocking // run function f on current stack function run(f) { @@ -254,12 +222,7 @@ const main = (() => { if (!state) { console.log(`Entering final state`) console.log(JSON.stringify(params)) - return record(params).then(() => { - if (callee) { - return wsk.actions.invoke({ name: callee.name, params: { $sessionId: callee.session, $result: params } }) - .catch(error => badRequest(`Failed to return to callee: ${encodeError(error).error}`)) - } - }).then(() => blocking ? params : ({ $session: session })) + return record(params).then(() => ({ $params: params, $session: session })) } console.log(`Entering ${state}`) @@ -308,22 +271,8 @@ const main = (() => { if (typeof stack.shift().let !== 'object') return badRequest(`The state named ${current} of type End popped an unexpected stack element`) break case 'Task': - if (typeof json.Action === 'string' && json.Action.substr(json.Action.length - 4) === '.app') { // invoke app - params.$callee = { name: process.env.__OW_ACTION_NAME, session } - return persist(fsm, state, stack, callee) - .then(() => wsk.actions.invoke({ name: json.Action, params }) - .catch(error => badRequest(`Failed to invoke app ${json.Action}: ${encodeError(error).error}`))) - .then(activation => db.rpushxAsync(sessionTraceKey, activation.activationId)) - .then(() => blocking ? getSessionResult() : { $session: session }) - } else if (typeof json.Action === 'string') { // invoke user action - const invocation = notify ? { name: json.Action, params, blocking: true } : { name: json.Action, params, notify: process.env.__OW_ACTION_NAME, cause: session } - return persist(fsm, state, stack, callee) - .then(() => wsk.actions.invoke(invocation) - .catch(error => error.error && error.error.response ? error.error : badRequest(`Failed to invoke action ${json.Action}: ${encodeError(error).error}`))) // catch error reponses - .then(activation => db.rpushxAsync(sessionTraceKey, activation.activationId) - .then(() => activation.response || !notify ? activation : new Promise(resolve => poll(activation.activationId, resolve)))) // poll if timeout - .then(activation => notify && wsk.actions.invoke({ name: process.env.__OW_ACTION_NAME, params: { $activationId: activation.activationId, $sessionId: session, $result: activation.response.result } })) - .then(() => blocking ? getSessionResult() : { $session: session }) + if (typeof json.Action === 'string') { // invoke user action using notification + return persist(fsm, state, stack).then(() => ({ $next: json.Action, $params: params, $session: session })) } else if (typeof json.Value !== 'undefined') { // value params = JSON.parse(JSON.stringify(json.Value)) inspect() From 2bb6c5c14a0322ef169c6cb7ba62a33102aefa2e Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 30 Nov 2017 11:12:20 -0500 Subject: [PATCH 02/62] No redis --- conductor.js | 353 +++++++++++++++++---------------------------------- 1 file changed, 117 insertions(+), 236 deletions(-) diff --git a/conductor.js b/conductor.js index 4c4b0bc..88212b1 100644 --- a/conductor.js +++ b/conductor.js @@ -35,35 +35,7 @@ function __eval__(__env__, main) { // keep outer namespace clean const main = (() => { - const redis = require('redis') - - // inline redis-promise to keep action code in a single file - redis.createAsyncClient = function () { - const client = redis.createClient(...arguments) - const noop = () => { } - let handler = noop - client.on('error', error => handler(error)) - require('redis-commands').list.forEach(f => client[`${f}Async`] = function () { - let failed = false - return new Promise((resolve, reject) => { - handler = error => { - handler = noop - failed = true - reject(error) - } - client[f](...arguments, (error, result) => { - handler = noop - return error ? reject(error) : resolve(result) - }) - }).catch(error => { - if (failed) client.end(true) - return Promise.reject(error) - }) - }) - return client - } - - let db // cached redis instance + const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) // encode error object const encodeError = error => ({ @@ -72,229 +44,138 @@ const main = (() => { }) // error status codes - const ok = () => ({ message: 'OK' }) const badRequest = error => Promise.reject({ code: 400, error }) - const notFound = error => Promise.reject({ code: 404, error }) - const gone = error => Promise.reject({ code: 410, error }) const internalError = error => Promise.reject(encodeError(error)) - const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) - - // catch all exceptions and rejected promises - return params => { - try { - return invoke(params).catch(internalError) - } catch (error) { - return internalError(error) - } - } + // catch all + return params => Promise.resolve().then(() => invoke(params)).catch(internalError) // do invocation function invoke(params) { // check parameters - if (!isObject(params.$config)) return badRequest('Missing $config parameter of type object') - if (typeof params.$config.redis !== 'string') return badRequest('Missing $config.redis parameter of type string') - if (typeof params.$config.expiration !== 'undefined' && typeof params.$config.expiration !== 'number') return badRequest('Type of $config.expiration parameter must be number') - if (typeof params.$session !== 'number' && typeof params.$session !== 'string') return badRequest('Type of $session parameter must be number or string') - if (typeof params.$invoke !== 'undefined' && !isObject(params.$invoke)) return badRequest('Type of $invoke parameter must be object') - if (typeof params.$invoke === 'undefined' && typeof params.$resume === 'undefined') return badRequest('Missing $invoke or $resume parameter') - - // configuration - const expiration = params.$config.expiration || (86400 * 7) - const resuming = params.$resume - const session = params.$session - - // redis keys - const apiKey = process.env.__OW_API_KEY.substring(0, process.env.__OW_API_KEY.indexOf(':')) - const sessionStateKey = `${apiKey}:session:live:${session}` - const sessionResultKey = `${apiKey}:session:done:${session}` - const sessionTraceKey = `${apiKey}:list:${session}` - const sessionsKey = `${apiKey}:all` - - // initialize redis instance - // TODO: check that redis config has not changed since last invocation - if (!db) db = redis.createAsyncClient(params.$config.redis) - - // retrieve session state from redis - function getSessionState() { - return db.lindexAsync(sessionStateKey, -1).then(result => { - if (typeof result !== 'string') return notFound(`Cannot find live session ${session}`) - const obj = JSON.parse(result) - if (!isObject(obj)) return internalError(`State of live session ${session} is not a JSON object`) - return obj - }) - } - - // resume suspended session - function resume() { - return db.rpushxAsync(sessionTraceKey, process.env.__OW_ACTIVATION_ID) - .then(() => getSessionState()) // obtain live session state - .then(result => { - if (!isObject(result.$fsm)) return badRequest(`State of session ${session} is not well formed`) - params.$invoke = result.$fsm - params.$state = result.$state - params.$stack = result.$stack - }) - } - - // start new session - function start() { - return db.rpushAsync(sessionTraceKey, process.env.__OW_ACTIVATION_ID) - .then(() => db.expireAsync(sessionTraceKey, expiration)) - .then(() => db.zaddAsync(sessionsKey, process.env.__OW_DEADLINE * 2, session)) - .then(() => db.lpushAsync(sessionStateKey, JSON.stringify({}))) - .then(() => db.ltrimAsync(sessionStateKey, -1, -1)) - .then(() => db.expireAsync(sessionStateKey, expiration)) - } - - // persist session state to redis - function persist($fsm, $state, $stack) { - // ensure using set-if-exists that the session has not been killed - return db.lsetAsync(sessionStateKey, -1, JSON.stringify({ $fsm, $state, $stack })) - .catch(() => gone(`Session ${session} has been killed`)) - } - - // record session result to redis - function record(result) { - return db.lsetAsync(sessionStateKey, -1, JSON.stringify(result)) - .then(() => db.rpoplpushAsync(sessionStateKey, sessionResultKey)) - .then(() => db.delAsync(sessionStateKey)) - .then(() => db.expireAsync(sessionResultKey, expiration)) - .then(() => db.zincrbyAsync(sessionsKey, 1, session)) - .catch(() => gone(`Session ${session} has been killed`)) - } - - // retrieve session state if resuming or initialize session state if not, step, push error in step to db if any - return (resuming ? resume() : start()).then(() => Promise.resolve().then(step).catch(error => record(encodeError(error)).then(() => Promise.reject(error)))) - - // one step of execution - function step() { - const fsm = params.$invoke // the action composition to run - if (typeof fsm.Entry !== 'string') return badRequest('The composition has no Entry field of type string') - if (!isObject(fsm.States)) return badRequest('The composition has no States field of type object') - if (typeof fsm.Exit !== 'string') return badRequest('The composition has no Exit field of type string') - - let state = resuming ? params.$state : (params.$state || fsm.Entry) - const stack = params.$stack || [] - - // wrap params if not a JSON object, branch to error handler if error - function inspect() { - if (!isObject(params) || Array.isArray(params) || params === null) { - params = { value: params } - } - if (typeof params.error !== 'undefined') { - params = { error: params.error } // discard all fields but the error field - state = undefined // abort unless there is a handler in the stack - while (stack.length > 0) { - if (state = stack.shift().catch) break - } + if (!isObject(params.$invoke)) return badRequest('Type of $invoke parameter must be object') + const fsm = params.$invoke + delete params.$invoke + + if (typeof fsm.Entry !== 'undefined' && typeof fsm.Entry !== 'string') return badRequest('The type of Entry field of the composition must be string') + if (!isObject(fsm.States)) return badRequest('The composition has no States field of type object') + if (typeof fsm.Exit !== 'string') return badRequest('The composition has no Exit field of type string') + if (typeof fsm.Stack !== 'undefined' && !Array.isArray(fsm.Stack)) return badRequest('The Stack field of the composition must be an array') + + let state = fsm.Entry + const stack = fsm.Stack || [] + + // wrap params if not a JSON object, branch to error handler if error + function inspect() { + if (!isObject(params) || Array.isArray(params) || params === null) { + params = { value: params } + } + if (typeof params.error !== 'undefined') { + params = { error: params.error } // discard all fields but the error field + state = undefined // abort unless there is a handler in the stack + while (stack.length > 0) { + if (state = stack.shift().catch) break } } + } - // handle error objects when resuming - if (resuming) inspect() - - // delete $ params - delete params.$config - delete params.$session - delete params.$invoke - delete params.$resume - delete params.$state - delete params.$stack - - // run function f on current stack - function run(f) { - function set(symbol, value) { - const element = stack.find(element => typeof element.let !== 'undefined' && typeof element.let[symbol] !== 'undefined') - if (typeof element !== 'undefined') element.let[symbol] = JSON.parse(JSON.stringify(value)) - } + // handle error objects when resuming + if (fsm.Stack) inspect() - const env = stack.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) - const result = __eval__(env, f)(params) - for (const name in env) { - set(name, env[name]) - } - return result + // run function f on current stack + function run(f) { + function set(symbol, value) { + const element = stack.find(element => typeof element.let !== 'undefined' && typeof element.let[symbol] !== 'undefined') + if (typeof element !== 'undefined') element.let[symbol] = JSON.parse(JSON.stringify(value)) } - while (true) { - // final state - if (!state) { - console.log(`Entering final state`) - console.log(JSON.stringify(params)) - return record(params).then(() => ({ $params: params, $session: session })) - } - - console.log(`Entering ${state}`) + const env = stack.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) + const result = __eval__(env, f)(params) + for (const name in env) { + set(name, env[name]) + } + return result + } - if (!isObject(fsm.States[state])) return badRequest(`The composition has no state named ${state}`) - const json = fsm.States[state] // json for current state - if (json.Type !== 'Choice' && typeof json.Next !== 'string' && state !== fsm.Exit) return badRequest(`The state named ${state} has no Next field`) - const current = state // current state - state = json.Next // default next state + while (true) { + // final state + if (!state) { + console.log(`Entering final state`) + console.log(JSON.stringify(params)) + return { $params: params } + } - switch (json.Type) { - case 'Choice': - if (typeof json.Then !== 'string') return badRequest(`The state named ${current} of type Choice has no Then field`) - if (typeof json.Else !== 'string') return badRequest(`The state named ${current} of type Choice has no Else field`) - state = params.value === true ? json.Then : json.Else - if (stack.length === 0) return badRequest(`The state named ${current} of type Choice attempted to pop from an empty stack`) - const top = stack.shift() - if (typeof top.params !== 'object') return badRequest(`The state named ${current} of type Choice popped an unexpected stack element`) - params = top.params - break - case 'Try': - if (typeof json.Handler !== 'string') return badRequest(`The state named ${current} of type Try has no Handler field`) - stack.unshift({ catch: json.Handler }) // register handler - break - case 'Catch': - if (stack.length === 0) return badRequest(`The state named ${current} of type Catch attempted to pop from an empty stack`) - if (typeof stack.shift().catch !== 'string') return badRequest(`The state named ${current} of type Catch popped an unexpected stack element`) - break - case 'Push': - stack.unshift({ params: JSON.parse(JSON.stringify(params)) }) - break - case 'Pop': - if (stack.length === 0) return badRequest(`The state named ${current} of type Pop attempted to pop from an empty stack`) - const tip = stack.shift() - if (typeof tip.params !== 'object') return badRequest(`The state named ${current} of type Pop popped an unexpected stack element`) - params = { result: params, params: tip.params } // combine current params with persisted params popped from stack - break - case 'Let': - stack.unshift({ let: {} }) - if (typeof json.Symbol !== 'string') return badRequest(`The state named ${current} of type Let has no Symbol field`) - if (typeof json.Value === 'undefined') return badRequest(`The state named ${current} of type Let has no Value field`) - stack[0].let[json.Symbol] = JSON.parse(JSON.stringify(json.Value)) - break - case 'End': - if (stack.length === 0) return badRequest(`The state named ${current} of type End attempted to pop from an empty stack`) - if (typeof stack.shift().let !== 'object') return badRequest(`The state named ${current} of type End popped an unexpected stack element`) - break - case 'Task': - if (typeof json.Action === 'string') { // invoke user action using notification - return persist(fsm, state, stack).then(() => ({ $next: json.Action, $params: params, $session: session })) - } else if (typeof json.Value !== 'undefined') { // value - params = JSON.parse(JSON.stringify(json.Value)) - inspect() - } else if (typeof json.Function === 'string') { // function - let result - try { - result = run(json.Function) - } catch (error) { - console.error(error) - result = { error: 'An error has occurred: ' + error } - } - params = typeof result === 'undefined' ? {} : JSON.parse(JSON.stringify(result)) - inspect() - } else { - return badRequest(`The kind field of the state named ${current} of type Task is missing`) + console.log(`Entering ${state}`) + + if (!isObject(fsm.States[state])) return badRequest(`The composition has no state named ${state}`) + const json = fsm.States[state] // json for current state + if (json.Type !== 'Choice' && typeof json.Next !== 'string' && state !== fsm.Exit) return badRequest(`The state named ${state} has no Next field`) + const current = state // current state + state = json.Next // default next state + + switch (json.Type) { + case 'Choice': + if (typeof json.Then !== 'string') return badRequest(`The state named ${current} of type Choice has no Then field`) + if (typeof json.Else !== 'string') return badRequest(`The state named ${current} of type Choice has no Else field`) + state = params.value === true ? json.Then : json.Else + if (stack.length === 0) return badRequest(`The state named ${current} of type Choice attempted to pop from an empty stack`) + const top = stack.shift() + if (typeof top.params !== 'object') return badRequest(`The state named ${current} of type Choice popped an unexpected stack element`) + params = top.params + break + case 'Try': + if (typeof json.Handler !== 'string') return badRequest(`The state named ${current} of type Try has no Handler field`) + stack.unshift({ catch: json.Handler }) // register handler + break + case 'Catch': + if (stack.length === 0) return badRequest(`The state named ${current} of type Catch attempted to pop from an empty stack`) + if (typeof stack.shift().catch !== 'string') return badRequest(`The state named ${current} of type Catch popped an unexpected stack element`) + break + case 'Push': + stack.unshift({ params: JSON.parse(JSON.stringify(params)) }) + break + case 'Pop': + if (stack.length === 0) return badRequest(`The state named ${current} of type Pop attempted to pop from an empty stack`) + const tip = stack.shift() + if (typeof tip.params !== 'object') return badRequest(`The state named ${current} of type Pop popped an unexpected stack element`) + params = { result: params, params: tip.params } // combine current params with persisted params popped from stack + break + case 'Let': + stack.unshift({ let: {} }) + if (typeof json.Symbol !== 'string') return badRequest(`The state named ${current} of type Let has no Symbol field`) + if (typeof json.Value === 'undefined') return badRequest(`The state named ${current} of type Let has no Value field`) + stack[0].let[json.Symbol] = JSON.parse(JSON.stringify(json.Value)) + break + case 'End': + if (stack.length === 0) return badRequest(`The state named ${current} of type End attempted to pop from an empty stack`) + if (typeof stack.shift().let !== 'object') return badRequest(`The state named ${current} of type End popped an unexpected stack element`) + break + case 'Task': + if (typeof json.Action === 'string') { + fsm.Entry = state + fsm.Stack = stack + return { $next: json.Action, $params: params, $invoke: fsm } + } else if (typeof json.Value !== 'undefined') { // value + params = JSON.parse(JSON.stringify(json.Value)) + inspect() + } else if (typeof json.Function === 'string') { // function + let result + try { + result = run(json.Function) + } catch (error) { + console.error(error) + result = { error: 'An error has occurred: ' + error } } - break - case 'Pass': - break - default: - return badRequest(`The state named ${current} has an unknown type`) - } + params = typeof result === 'undefined' ? {} : JSON.parse(JSON.stringify(result)) + inspect() + } else { + return badRequest(`The kind field of the state named ${current} of type Task is missing`) + } + break + case 'Pass': + break + default: + return badRequest(`The state named ${current} has an unknown type`) } } } From 30ee0417292d6de1ea4a4d4833995af4d168230e Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 30 Nov 2017 13:41:54 -0500 Subject: [PATCH 03/62] Update naming convention --- conductor.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/conductor.js b/conductor.js index 88212b1..6612c66 100644 --- a/conductor.js +++ b/conductor.js @@ -56,12 +56,12 @@ const main = (() => { if (!isObject(params.$invoke)) return badRequest('Type of $invoke parameter must be object') const fsm = params.$invoke delete params.$invoke - + if (typeof fsm.Entry !== 'undefined' && typeof fsm.Entry !== 'string') return badRequest('The type of Entry field of the composition must be string') if (!isObject(fsm.States)) return badRequest('The composition has no States field of type object') if (typeof fsm.Exit !== 'string') return badRequest('The composition has no Exit field of type string') if (typeof fsm.Stack !== 'undefined' && !Array.isArray(fsm.Stack)) return badRequest('The Stack field of the composition must be an array') - + let state = fsm.Entry const stack = fsm.Stack || [] @@ -102,7 +102,7 @@ const main = (() => { if (!state) { console.log(`Entering final state`) console.log(JSON.stringify(params)) - return { $params: params } + return { params } } console.log(`Entering ${state}`) @@ -154,7 +154,7 @@ const main = (() => { if (typeof json.Action === 'string') { fsm.Entry = state fsm.Stack = stack - return { $next: json.Action, $params: params, $invoke: fsm } + return { action: json.Action, params: params, state: { $invoke: fsm } } } else if (typeof json.Value !== 'undefined') { // value params = JSON.parse(JSON.stringify(json.Value)) inspect() From 4fc37b308ab1bb85507006ff261071a83d6750da Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 30 Nov 2017 13:53:05 -0500 Subject: [PATCH 04/62] Tweak --- conductor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor.js b/conductor.js index 6612c66..8d4b6ca 100644 --- a/conductor.js +++ b/conductor.js @@ -154,7 +154,7 @@ const main = (() => { if (typeof json.Action === 'string') { fsm.Entry = state fsm.Stack = stack - return { action: json.Action, params: params, state: { $invoke: fsm } } + return { action: json.Action, params, state: { $invoke: fsm } } } else if (typeof json.Value !== 'undefined') { // value params = JSON.parse(JSON.stringify(json.Value)) inspect() From f54322362f76ae118039a92b90ce306a6fbf754e Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 3 Jan 2018 14:17:58 -0500 Subject: [PATCH 05/62] Hybrid composer/conductor --- composer.js | 255 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 227 insertions(+), 28 deletions(-) diff --git a/composer.js b/composer.js index 80bd59d..8eb35c5 100644 --- a/composer.js +++ b/composer.js @@ -16,9 +16,14 @@ 'use strict' +// composer module + const clone = require('clone') const util = require('util') const fs = require('fs') +const path = require('path') +const os = require('os') +const openwhisk = require('openwhisk') class ComposerError extends Error { constructor(message, cause) { @@ -32,27 +37,28 @@ function chain(front, back) { front.States.push(...back.States) front.Exit.Next = back.Entry front.Exit = back.Exit + front.Manifest.push(...back.Manifest) return front } function push(id) { const Entry = { Type: 'Push', id } - return { Entry, States: [Entry], Exit: Entry } + return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } function pop(id) { const Entry = { Type: 'Pop', id } - return { Entry, States: [Entry], Exit: Entry } + return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } function begin(id, symbol, value) { const Entry = { Type: 'Let', Symbol: symbol, Value: value, id } - return { Entry, States: [Entry], Exit: Entry } + return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } function end(id) { const Entry = { Type: 'End', id } - return { Entry, States: [Entry], Exit: Entry } + return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) @@ -63,12 +69,11 @@ class Composer { if (options != null && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) const id = {} let Entry + let Manifest = [] if (obj == null) { // identity function (must throw errors if any) Entry = { Type: 'Task', Helper: 'null', Function: 'params => params', id } } else if (typeof obj === 'object' && typeof obj.Entry === 'object' && Array.isArray(obj.States) && typeof obj.Exit === 'object') { // an action composition return clone(obj) - } else if (typeof obj === 'object' && typeof obj.Entry === 'string' && typeof obj.States === 'object' && typeof obj.Exit === 'string') { // a compiled composition - return this.decompile(obj) } else if (typeof obj === 'function') { // function Entry = { Type: 'Task', Function: obj.toString(), id } } else if (typeof obj === 'string') { // action @@ -78,7 +83,14 @@ class Composer { } else { // error throw new ComposerError('Invalid composition argument', obj) } - return { Entry, States: [Entry], Exit: Entry } + return { Entry, States: [Entry], Exit: Entry, Manifest } + } + + taskFromFile(name, filename) { + if (typeof name !== 'string') throw new ComposerError('Invalid name argument in taskFromFile', name) + if (typeof filename !== 'string') throw new ComposerError('Invalid filename argument in taskFromFile', filename) + const Entry = { Type: 'Task', Action: name, id: {} } + return { Entry, States: [Entry], Exit: Entry, Manifest: [{ name, action: fs.readFileSync(filename, { encoding: 'utf8' }) }] } } sequence() { @@ -224,10 +236,12 @@ class Composer { const id = {} if (typeof json === 'function') throw new ComposerError('Value cannot be a function', json.toString()) const Entry = { Type: 'Task', Value: typeof json === 'undefined' ? {} : json, id } - return { Entry, States: [Entry], Exit: Entry } + return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } - compile(obj, filename) { + compile(name, obj, filename) { + if (typeof name !== 'string') throw new ComposerError('Invalid name argument in compile', name) + if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid optional filename argument in compile', filename) if (typeof obj !== 'object' || typeof obj.Entry !== 'object' || !Array.isArray(obj.States) || typeof obj.Exit !== 'object') { throw new ComposerError('Invalid argument to compile', obj) } @@ -255,30 +269,215 @@ class Composer { obj.States.forEach(state => { delete state.id }) - const app = { Entry, States, Exit } - if (filename) fs.writeFileSync(filename, JSON.stringify(app, null, 4), { encoding: 'utf8' }) + const action = `${main}\nconst __composition__ = ${JSON.stringify({ Entry, States, Exit }, null, 4)}\n` + if (filename) fs.writeFileSync(filename, action, { encoding: 'utf8' }) + const app = this.task(name) + app.Manifest = clone(obj.Manifest) + app.Manifest.push({ name, action, annotations: { conductor: { Entry, States, Exit } } }) return app } - decompile(obj) { - if (typeof obj !== 'object' || typeof obj.Entry !== 'string' || typeof obj.States !== 'object' || typeof obj.Exit !== 'string') { - throw new ComposerError('Invalid argument to decompile', obj) + deploy(obj, options) { + if (typeof obj !== 'object' || !Array.isArray(obj.Manifest) || obj.Manifest.length === 0) { + throw new ComposerError('Invalid argument to deploy', obj) } - obj = clone(obj) - const States = [] - const ids = [] - for (const name in obj.States) { - const state = obj.States[name] - if (state.Next) state.Next = obj.States[state.Next] - if (state.Then) state.Then = obj.States[state.Then] - if (state.Else) state.Else = obj.States[state.Else] - if (state.Handler) state.Handler = obj.States[state.Handler] - const id = parseInt(name.substring(name.lastIndexOf('_') + 1)) - state.id = ids[id] = typeof ids[id] !== 'undefined' ? ids[id] : {} - States.push(state) - } - return { Entry: obj.States[obj.Entry], States, Exit: obj.States[obj.Exit] } + + // try to extract apihost and key + let apihost + let api_key + + try { + const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') + const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') + + for (let line of lines) { + let parts = line.trim().split('=') + if (parts.length === 2) { + if (parts[0] === 'APIHOST') { + apihost = parts[1] + } else if (parts[0] === 'AUTH') { + api_key = parts[1] + } + } + } + } catch (error) { } + + const wsk = openwhisk(Object.assign({ apihost, api_key }, options)) + + // return the count of successfully deployed actions + return clone(obj.Manifest).reduce( + (promise, action) => promise.then(i => wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0)).then(i => `${i}/${obj.Manifest.length}`) } } module.exports = new Composer() + +// conductor action + +function main(params) { + // evaluate main exposing the fields of __env__ as variables + function __eval__(__env__, main) { + main = `(${main})` + let __eval__ = '__eval__=undefined;params=>{try{' + for (const name in __env__) { + __eval__ += `var ${name}=__env__['${name}'];` + } + __eval__ += 'return eval(main)(params)}finally{' + for (const name in __env__) { + __eval__ += `__env__['${name}']=${name};` + } + __eval__ += '}}' + return eval(__eval__) + } + + // keep outer namespace clean + return (() => { + const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) + + // encode error object + const encodeError = error => ({ + code: typeof error.code === 'number' && error.code || 500, + error: (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error) || 'An internal error occurred' + }) + + // error status codes + const badRequest = error => Promise.reject({ code: 400, error }) + const internalError = error => Promise.reject(encodeError(error)) + + // catch all + return Promise.resolve().then(() => invoke(params)).catch(internalError) + + // do invocation + function invoke(params) { + const fsm = __composition__ + + if (typeof fsm.Entry !== 'undefined' && typeof fsm.Entry !== 'string') return badRequest('The type of Entry field of the composition must be string') + if (!isObject(fsm.States)) return badRequest('The composition has no States field of type object') + if (typeof fsm.Exit !== 'string') return badRequest('The composition has no Exit field of type string') + + let state = fsm.Entry + let stack = [] + + // check parameters + if (typeof params.$resume !== 'undefined') { + if (!isObject(params.$resume)) return badRequest('Type of optional $resume parameter must be object') + state = params.$resume.state + if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('Type of optional $resume.state parameter must be string') + stack = params.$resume.stack + if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('Type of optional $resume.state parameter must be string') + if (!Array.isArray(stack)) return badRequest('The type of $resume.stack must be an array') + delete params.$resume + inspect() // handle error objects when resuming + } + + // wrap params if not a JSON object, branch to error handler if error + function inspect() { + if (!isObject(params) || Array.isArray(params) || params === null) { + params = { value: params } + } + if (typeof params.error !== 'undefined') { + params = { error: params.error } // discard all fields but the error field + state = undefined // abort unless there is a handler in the stack + while (stack.length > 0) { + if (state = stack.shift().catch) break + } + } + } + + // run function f on current stack + function run(f) { + function set(symbol, value) { + const element = stack.find(element => typeof element.let !== 'undefined' && typeof element.let[symbol] !== 'undefined') + if (typeof element !== 'undefined') element.let[symbol] = JSON.parse(JSON.stringify(value)) + } + + const env = stack.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) + const result = __eval__(env, f)(params) + for (const name in env) { + set(name, env[name]) + } + return result + } + + while (true) { + // final state + if (!state) { + console.log(`Entering final state`) + console.log(JSON.stringify(params)) + return { params } + } + + console.log(`Entering ${state}`) + + if (!isObject(fsm.States[state])) return badRequest(`The composition has no state named ${state}`) + const json = fsm.States[state] // json for current state + if (json.Type !== 'Choice' && typeof json.Next !== 'string' && state !== fsm.Exit) return badRequest(`The state named ${state} has no Next field`) + const current = state // current state + state = json.Next // default next state + + switch (json.Type) { + case 'Choice': + if (typeof json.Then !== 'string') return badRequest(`The state named ${current} of type Choice has no Then field`) + if (typeof json.Else !== 'string') return badRequest(`The state named ${current} of type Choice has no Else field`) + state = params.value === true ? json.Then : json.Else + if (stack.length === 0) return badRequest(`The state named ${current} of type Choice attempted to pop from an empty stack`) + const top = stack.shift() + if (typeof top.params !== 'object') return badRequest(`The state named ${current} of type Choice popped an unexpected stack element`) + params = top.params + break + case 'Try': + if (typeof json.Handler !== 'string') return badRequest(`The state named ${current} of type Try has no Handler field`) + stack.unshift({ catch: json.Handler }) // register handler + break + case 'Catch': + if (stack.length === 0) return badRequest(`The state named ${current} of type Catch attempted to pop from an empty stack`) + if (typeof stack.shift().catch !== 'string') return badRequest(`The state named ${current} of type Catch popped an unexpected stack element`) + break + case 'Push': + stack.unshift({ params: JSON.parse(JSON.stringify(params)) }) + break + case 'Pop': + if (stack.length === 0) return badRequest(`The state named ${current} of type Pop attempted to pop from an empty stack`) + const tip = stack.shift() + if (typeof tip.params !== 'object') return badRequest(`The state named ${current} of type Pop popped an unexpected stack element`) + params = { result: params, params: tip.params } // combine current params with persisted params popped from stack + break + case 'Let': + stack.unshift({ let: {} }) + if (typeof json.Symbol !== 'string') return badRequest(`The state named ${current} of type Let has no Symbol field`) + if (typeof json.Value === 'undefined') return badRequest(`The state named ${current} of type Let has no Value field`) + stack[0].let[json.Symbol] = JSON.parse(JSON.stringify(json.Value)) + break + case 'End': + if (stack.length === 0) return badRequest(`The state named ${current} of type End attempted to pop from an empty stack`) + if (typeof stack.shift().let !== 'object') return badRequest(`The state named ${current} of type End popped an unexpected stack element`) + break + case 'Task': + if (typeof json.Action === 'string') { + return { action: json.Action, params, state: { $resume: { state, stack } } } + } else if (typeof json.Value !== 'undefined') { // value + params = JSON.parse(JSON.stringify(json.Value)) + inspect() + } else if (typeof json.Function === 'string') { // function + let result + try { + result = run(json.Function) + } catch (error) { + console.error(error) + result = { error: 'An error has occurred: ' + error } + } + params = typeof result === 'undefined' ? {} : JSON.parse(JSON.stringify(result)) + inspect() + } else { + return badRequest(`The kind field of the state named ${current} of type Task is missing`) + } + break + case 'Pass': + break + default: + return badRequest(`The state named ${current} has an unknown type`) + } + } + } + })() +} From 242b36be3c7c4876e758bc6cdadeaa89eb5d6284 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 3 Jan 2018 14:52:20 -0500 Subject: [PATCH 06/62] Update openwhisk dependency --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a3a8b3e..b5e242d 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ "openwhisk" ], "dependencies": { + "openwhisk": "^3.11.0", "redis": "^2.8.0", "clone": "^2.1.1" }, "devDependencies": { - "mocha": "^3.5.0", - "openwhisk": "git://github.com/starpit/openwhisk-client-js.git#add_client_timeout" + "mocha": "^3.5.0" }, "author": { "name": "Olivier Tardieu", From 2d5bee709187370d11df5c9be433cf58fae68a36 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 3 Jan 2018 16:38:33 -0500 Subject: [PATCH 07/62] Fixes and tests --- composer.js | 62 +++++++++-------- conductor.js | 182 ------------------------------------------------ manager.js | 118 ------------------------------- test-harness.js | 76 -------------------- test/test.js | 123 ++------------------------------ 5 files changed, 39 insertions(+), 522 deletions(-) delete mode 100644 conductor.js delete mode 100644 manager.js delete mode 100644 test-harness.js diff --git a/composer.js b/composer.js index 8eb35c5..904b10a 100644 --- a/composer.js +++ b/composer.js @@ -64,6 +64,30 @@ function end(id) { const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) class Composer { + constructor(options = {}) { + // try to extract apihost and key + let apihost + let api_key + + try { + const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') + const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') + + for (let line of lines) { + let parts = line.trim().split('=') + if (parts.length === 2) { + if (parts[0] === 'APIHOST') { + apihost = parts[1] + } else if (parts[0] === 'AUTH') { + api_key = parts[1] + } + } + } + } catch (error) { } + + this.wsk = openwhisk(Object.assign({ apihost, api_key }, options)) + } + task(obj, options) { if (options != null && options.output) return this.assign(options.output, obj, options.input) if (options != null && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) @@ -149,7 +173,8 @@ class Composer { States.push(...body.States, pop, ...handler.States, Exit) body.Exit.Next = pop handler.Exit.Next = Exit - return { Entry, States, Exit } + body.Manifest.push(...handler.Manifest) + return { Entry, States, Exit, Manifest: body.Manifest } } retain(body, flag = false) { @@ -277,36 +302,13 @@ class Composer { return app } - deploy(obj, options) { - if (typeof obj !== 'object' || !Array.isArray(obj.Manifest) || obj.Manifest.length === 0) { - throw new ComposerError('Invalid argument to deploy', obj) - } - - // try to extract apihost and key - let apihost - let api_key - - try { - const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') - const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') - - for (let line of lines) { - let parts = line.trim().split('=') - if (parts.length === 2) { - if (parts[0] === 'APIHOST') { - apihost = parts[1] - } else if (parts[0] === 'AUTH') { - api_key = parts[1] - } - } - } - } catch (error) { } - - const wsk = openwhisk(Object.assign({ apihost, api_key }, options)) + deploy(obj) { + if (typeof obj === 'object' && Array.isArray(obj.Manifest)) obj = obj.Manifest + if (!Array.isArray(obj) || obj.length === 0) throw new ComposerError('Invalid argument to deploy', obj) // return the count of successfully deployed actions - return clone(obj.Manifest).reduce( - (promise, action) => promise.then(i => wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0)).then(i => `${i}/${obj.Manifest.length}`) + return clone(obj).reduce( + (promise, action) => promise.then(i => this.wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0)).then(i => `${i}/${obj.length}`) } } @@ -404,7 +406,7 @@ function main(params) { if (!state) { console.log(`Entering final state`) console.log(JSON.stringify(params)) - return { params } + if (params.error) return params; else return { params } } console.log(`Entering ${state}`) diff --git a/conductor.js b/conductor.js deleted file mode 100644 index 8d4b6ca..0000000 --- a/conductor.js +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2017 IBM Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Run a composition - -'use strict' - -// evaluate main exposing the fields of __env__ as variables -function __eval__(__env__, main) { - main = `(${main})` - let __eval__ = '__eval__=undefined;params=>{try{' - for (const name in __env__) { - __eval__ += `var ${name}=__env__['${name}'];` - } - __eval__ += 'return eval(main)(params)}finally{' - for (const name in __env__) { - __eval__ += `__env__['${name}']=${name};` - } - __eval__ += '}}' - return eval(__eval__) -} - -// keep outer namespace clean -const main = (() => { - const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) - - // encode error object - const encodeError = error => ({ - code: typeof error.code === 'number' && error.code || 500, - error: (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error) || 'An internal error occurred' - }) - - // error status codes - const badRequest = error => Promise.reject({ code: 400, error }) - const internalError = error => Promise.reject(encodeError(error)) - - // catch all - return params => Promise.resolve().then(() => invoke(params)).catch(internalError) - - // do invocation - function invoke(params) { - // check parameters - if (!isObject(params.$invoke)) return badRequest('Type of $invoke parameter must be object') - const fsm = params.$invoke - delete params.$invoke - - if (typeof fsm.Entry !== 'undefined' && typeof fsm.Entry !== 'string') return badRequest('The type of Entry field of the composition must be string') - if (!isObject(fsm.States)) return badRequest('The composition has no States field of type object') - if (typeof fsm.Exit !== 'string') return badRequest('The composition has no Exit field of type string') - if (typeof fsm.Stack !== 'undefined' && !Array.isArray(fsm.Stack)) return badRequest('The Stack field of the composition must be an array') - - let state = fsm.Entry - const stack = fsm.Stack || [] - - // wrap params if not a JSON object, branch to error handler if error - function inspect() { - if (!isObject(params) || Array.isArray(params) || params === null) { - params = { value: params } - } - if (typeof params.error !== 'undefined') { - params = { error: params.error } // discard all fields but the error field - state = undefined // abort unless there is a handler in the stack - while (stack.length > 0) { - if (state = stack.shift().catch) break - } - } - } - - // handle error objects when resuming - if (fsm.Stack) inspect() - - // run function f on current stack - function run(f) { - function set(symbol, value) { - const element = stack.find(element => typeof element.let !== 'undefined' && typeof element.let[symbol] !== 'undefined') - if (typeof element !== 'undefined') element.let[symbol] = JSON.parse(JSON.stringify(value)) - } - - const env = stack.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) - const result = __eval__(env, f)(params) - for (const name in env) { - set(name, env[name]) - } - return result - } - - while (true) { - // final state - if (!state) { - console.log(`Entering final state`) - console.log(JSON.stringify(params)) - return { params } - } - - console.log(`Entering ${state}`) - - if (!isObject(fsm.States[state])) return badRequest(`The composition has no state named ${state}`) - const json = fsm.States[state] // json for current state - if (json.Type !== 'Choice' && typeof json.Next !== 'string' && state !== fsm.Exit) return badRequest(`The state named ${state} has no Next field`) - const current = state // current state - state = json.Next // default next state - - switch (json.Type) { - case 'Choice': - if (typeof json.Then !== 'string') return badRequest(`The state named ${current} of type Choice has no Then field`) - if (typeof json.Else !== 'string') return badRequest(`The state named ${current} of type Choice has no Else field`) - state = params.value === true ? json.Then : json.Else - if (stack.length === 0) return badRequest(`The state named ${current} of type Choice attempted to pop from an empty stack`) - const top = stack.shift() - if (typeof top.params !== 'object') return badRequest(`The state named ${current} of type Choice popped an unexpected stack element`) - params = top.params - break - case 'Try': - if (typeof json.Handler !== 'string') return badRequest(`The state named ${current} of type Try has no Handler field`) - stack.unshift({ catch: json.Handler }) // register handler - break - case 'Catch': - if (stack.length === 0) return badRequest(`The state named ${current} of type Catch attempted to pop from an empty stack`) - if (typeof stack.shift().catch !== 'string') return badRequest(`The state named ${current} of type Catch popped an unexpected stack element`) - break - case 'Push': - stack.unshift({ params: JSON.parse(JSON.stringify(params)) }) - break - case 'Pop': - if (stack.length === 0) return badRequest(`The state named ${current} of type Pop attempted to pop from an empty stack`) - const tip = stack.shift() - if (typeof tip.params !== 'object') return badRequest(`The state named ${current} of type Pop popped an unexpected stack element`) - params = { result: params, params: tip.params } // combine current params with persisted params popped from stack - break - case 'Let': - stack.unshift({ let: {} }) - if (typeof json.Symbol !== 'string') return badRequest(`The state named ${current} of type Let has no Symbol field`) - if (typeof json.Value === 'undefined') return badRequest(`The state named ${current} of type Let has no Value field`) - stack[0].let[json.Symbol] = JSON.parse(JSON.stringify(json.Value)) - break - case 'End': - if (stack.length === 0) return badRequest(`The state named ${current} of type End attempted to pop from an empty stack`) - if (typeof stack.shift().let !== 'object') return badRequest(`The state named ${current} of type End popped an unexpected stack element`) - break - case 'Task': - if (typeof json.Action === 'string') { - fsm.Entry = state - fsm.Stack = stack - return { action: json.Action, params, state: { $invoke: fsm } } - } else if (typeof json.Value !== 'undefined') { // value - params = JSON.parse(JSON.stringify(json.Value)) - inspect() - } else if (typeof json.Function === 'string') { // function - let result - try { - result = run(json.Function) - } catch (error) { - console.error(error) - result = { error: 'An error has occurred: ' + error } - } - params = typeof result === 'undefined' ? {} : JSON.parse(JSON.stringify(result)) - inspect() - } else { - return badRequest(`The kind field of the state named ${current} of type Task is missing`) - } - break - case 'Pass': - break - default: - return badRequest(`The state named ${current} has an unknown type`) - } - } - } -})() diff --git a/manager.js b/manager.js deleted file mode 100644 index d4627b1..0000000 --- a/manager.js +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2017 IBM Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const redis = require('./redis-promise') - -let apikey -let args -let expiration -const addLivePrefix = session => `${apikey}:session:live:${session}` -const addDonePrefix = session => `${apikey}:session:done:${session}` -const addListPrefix = session => `${apikey}:list:${session}` -const sessionsKey = () => `${apikey}:all` - -const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) - -class Client { - constructor(config) { - apikey = config.key || config - expiration = config.expiration || 86400 * 7 - args = Array.prototype.slice.call(arguments, 1) - this.client = redis.createAsyncClient(...args) - } - - register(session) { - return this.client.lpushAsync(addLivePrefix(session), JSON.stringify({})) - .then(() => this.client.ltrimAsync(addLivePrefix(session), -1, -1)) - .then(() => this.client.expireAsync(addLivePrefix(session), expiration)) - .then(() => this.client.existsAsync(addDonePrefix(session))) - .then(n => n && this.client.delAsync(addLivePrefix(session))) - } - - // timeout in seconds, set block to true to block even if session does not exists - get(session, timeout, block) { - return this.client.lindexAsync(addDonePrefix(session), 0).then(result => { - if (typeof result === 'string' || typeof timeout === 'undefined') return result // got a result or not willing to wait - return this.client.existsAsync(addLivePrefix(session), addDonePrefix(session)).then(n => { - if (!block && n === 0) throw `Cannot find session ${session}` // not willing to wait for session to appear - }).then(() => { - let other = redis.createAsyncClient(...args) // use separate client for blocking read - return other.brpoplpushAsync(addDonePrefix(session), addDonePrefix(session), timeout).then(result => other.quitAsync().then(() => result)) - }) - }).then(result => { // parse result - if (typeof result !== 'string') throw `Cannot find result of session ${session}` - const obj = JSON.parse(result) - if (!isObject(obj)) throw `Result of session ${session} is not a JSON object` - return obj - }) - } - - kill(session) { - return this.client.delAsync(addLivePrefix(session)).then(count => { - if (count === 1) return this.client.delAsync(addListPrefix(session)).then(() => this.client.zremAsync(sessionsKey(), session)).then(() => `OK`) - throw `Cannot find live session ${session}` - }) - } - - purge(session) { - return this.client.delAsync(addLivePrefix(session), addDonePrefix(session), addListPrefix(session)).then(count => { - if (count !== 0) return this.client.zremAsync(sessionsKey(), session).then(() => `OK`) - throw `Cannot find session ${session}` - }) - } - - trace(session) { - return this.client.lrangeAsync(addListPrefix(session), 0, -1).then(trace => { - if (trace.length > 0) return { trace } - throw `Cannot find trace for session ${session}` - }) - } - - flush() { - return this.client.keysAsync(`${apikey}:*`).then(keys => keys.length > 0 ? this.client.delAsync(keys) : 0) - } - - last({ limit = 30, skip = 0 } = {}) { - limit = Math.max(1, Math.min(200, limit)) // default limit is 30, max limit is 200 - return this.client.zrevrangeAsync(sessionsKey(), 0, 0, 'WITHSCORES').then(result => - result.length ? this.client.zremrangebyscoreAsync(sessionsKey(), '-inf', parseInt(result[1]) - expiration * 2000) - .then(() => skip === 0 && limit === 1 ? result : this.client.zrevrangebyscoreAsync(sessionsKey(), 'inf', '-inf', 'WITHSCORES', 'LIMIT', skip, limit)) - .then(result => result.reduce(function (dst, session, index, src) { - if (index % 2 === 0) { - const time = parseInt(src[index + 1]) - dst.push({ session, time: (time - time % 2) / 2, live: !(time & 1) }) - } - return dst - }, [])) : []) - } - - list(options) { - return this.last(options).then(list => { - const live = [] - const done = [] - list.forEach(entry => (entry.live ? live : done).push(entry.session)) - return { live: live, done, next: 0 } - }) - } - - quit() { - return this.client.quitAsync() - } -} - -module.exports = function () { return new Client(...arguments) } diff --git a/test-harness.js b/test-harness.js deleted file mode 100644 index fb99e81..0000000 --- a/test-harness.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2017 IBM Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const fs = require('fs') -const path = require('path') -const os = require('os') -const openwhisk = require('openwhisk') -const manager = require('./manager') -let conductor - -class Client { - constructor(options = {}) { - let apihost = process.env.__OW_API_HOST - let api_key = process.env.__OW_API_KEY - let ignore_certs = true - let redis = process.env.REDIS - - try { - const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') - const lines = fs.readFileSync(wskpropsPath).toString('utf8').split('\n') - - for (let line of lines) { - let parts = line.trim().split('=') - if (parts.length === 2) { - if (parts[0] === 'APIHOST') { - apihost = parts[1] - } else if (parts[0] === 'AUTH') { - api_key = parts[1] - } else if (parts[0] === 'REDIS') { - redis = parts[1] - } - } - } - ignore_certs = apihost.indexOf('bluemix') == -1 - } catch (error) { } - - this.wsk = openwhisk(Object.assign({ apihost, api_key, ignore_certs }, options)) - - const action_body = this.wsk.actions.action_body - this.wsk.actions.action_body = (options) => { - const body = action_body(options) - if (options.limits) { - body.limits = options.limits - } - return body - } - - this.mgr = manager(api_key.substring(0, api_key.indexOf(':')), options.redis || redis) - - const name = 'conductor' - const action = require('fs').readFileSync(require.resolve('./conductor'), { encoding: 'utf8' }) - const params = { $config: { redis: redis, notify: true } } - conductor = { name, action, params, limits: { timeout: 300000 } } - } - - deploy() { - return this.wsk.actions.update(conductor) - } -} - -module.exports = function () { return new Client(...arguments) } diff --git a/test/test.js b/test/test.js index b64bacd..d7f255c 100644 --- a/test/test.js +++ b/test/test.js @@ -1,76 +1,18 @@ const assert = require('assert') const composer = require('../composer') -const harness = require('../test-harness')() -const wsk = harness.wsk -const mgr = harness.mgr -const run = params => wsk.actions.invoke({ name: 'conductor', params, blocking: true }) -const invoke = (task, params, $blocking = true) => run(Object.assign({ $invoke: composer.compile(task), $blocking }, params)) +const name = 'composer-test-action' -let activationId +const invoke = (task, params = {}, blocking = true) => composer.deploy(composer.compile(name, task)).then(() => composer.wsk.actions.invoke({ name, params, blocking })) describe('composer', function () { this.timeout(20000) before('deploy conductor and sample actions', function () { - return harness.deploy() - .then(() => wsk.actions.update({ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' })) - .then(() => wsk.actions.update({ name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' })) - .then(() => wsk.actions.update({ name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' })) - .then(() => wsk.actions.update({ name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' })) - }) - - it('flush', function () { - return mgr.flush() - }) - - it('history must be clean', function () { - return mgr.list().then(result => assert.ok(Array.isArray(result.live) && Array.isArray(result.done) && typeof result.next === 'number' - && result.live.length === 0 && result.done.length === 0 && result.next === 0)) - }) - - describe('first composition', function () { - it('identity task must return input object', function () { - return invoke(composer.task(), { foo: 'bar' }).then(activation => { - activationId = activation.activationId - return assert.deepEqual(activation.response.result, { foo: 'bar' }) - }) - }) - - it('check history', function () { - return mgr.list().then(result => assert.ok(result.live.length === 0 && result.done.length === 1 && result.done[0] === activationId && result.next === 0)) - }) - - it('check trace', function () { - return mgr.trace(activationId).then(result => assert.ok(result.trace.length === 1 && result.trace[0] === activationId)) - }) - }) - - describe('invalid conductor invocations', function () { - it('missing both $sessionId and $invoke must fail with 400', function () { - return run({}).then(() => assert.fail(), activation => assert.equal(activation.error.response.result.error.code, 400)) - }) - }) - - describe('nonexistent session', function () { - it('resume nonexistent session must fail with 404 (and not record session result)', function () { - return run({ $sessionId: 'foo', $invoke: composer.task(), params: {} }).then(() => assert.fail(), activation => assert.equal(activation.error.response.result.error.code, 404)) - }) - - it('get nonexistent session must throw', function () { - return mgr.get('foo').then(() => assert.fail(), result => assert.equal(result, 'Cannot find result of session foo')) - }) - - it('kill nonexistent session must throw', function () { - return mgr.kill('foo').then(() => assert.fail(), result => assert.equal(result, 'Cannot find live session foo')) - }) - - it('purge nonexistent session must throw', function () { - return mgr.purge('foo').then(() => assert.fail(), result => assert.equal(result, 'Cannot find session foo')) - }) - - it('trace nonexistent session must throw', function () { - return mgr.trace('foo').then(() => assert.fail(), result => assert.equal(result, 'Cannot find trace for session foo')) - }) + return composer.deploy( + [{ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' }, + { name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' }, + { name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' }, + { name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' }]) }) describe('blocking invocations', function () { @@ -263,55 +205,4 @@ describe('composer', function () { }) }) }) - - describe('non-blocking invocations', function () { - it('simple app must return session id', function () { - return invoke(composer.task(() => 42), {}, false).then(activation => assert.ok(activation.response.result.$session)) - }) - - it('complex app must return session id', function () { - return invoke(composer.task('DivideByTwo'), {}, false).then(activation => assert.ok(activation.response.result.$session)) - }) - - it('get after execution must succeed', function () { - return invoke(composer.task('DivideByTwo'), { n: 42 }, false) - .then(activation => new Promise(resolve => setTimeout(() => resolve(activation), 3000))) - .then(activation => mgr.get(activation.response.result.$session)) - .then(result => assert.deepEqual(result, { n: 21 })) - }) - - it('get during execution must fail', function () { - let session - return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 10 }, false) - .then(activation => mgr.get(session = activation.response.result.$session)) - .then(() => assert.fail(), result => assert.equal(result, `Cannot find result of session ${session}`)) - }) - - it('kill after execution must fail', function () { - let session - return invoke(composer.task('DivideByTwo'), { n: 42 }, false) - .then(activation => new Promise(resolve => setTimeout(() => resolve(activation), 3000))) - .then(activation => mgr.kill(session = activation.response.result.$session)) - .then(() => assert.fail(), result => assert.equal(result, `Cannot find live session ${session}`)) - }) - - it('kill during execution must succeed', function () { - return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 10 }, false) - .then(activation => mgr.kill(activation.response.result.$session)) - .then(result => assert.deepEqual(result, 'OK')) - }) - - it('purge after execution must succeed', function () { - return invoke(composer.task('DivideByTwo'), { n: 42 }, false) - .then(activation => new Promise(resolve => setTimeout(() => resolve(activation), 3000))) - .then(activation => mgr.purge(activation.response.result.$session)) - .then(result => assert.deepEqual(result, 'OK')) - }) - - it('purge during execution must succeed', function () { - return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 10 }, false) - .then(activation => mgr.purge(activation.response.result.$session)) - .then(result => assert.deepEqual(result, 'OK')) - }) - }) }) From 486041256b4b3491e601adbea724ed5fd53a298b Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 3 Jan 2018 16:38:55 -0500 Subject: [PATCH 08/62] Cleanup --- redis-promise.js | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 redis-promise.js diff --git a/redis-promise.js b/redis-promise.js deleted file mode 100644 index 4919bd3..0000000 --- a/redis-promise.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2017 IBM Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const redis = require('redis') - -redis.createAsyncClient = function () { - const client = redis.createClient(...arguments) - const noop = () => { } - let handler = noop - client.on('error', error => handler(error)) - require('redis-commands').list.forEach(f => client[`${f}Async`] = function () { - let failed = false - return new Promise((resolve, reject) => { - handler = error => { - handler = noop - failed = true - reject(error) - } - client[f](...arguments, (error, result) => { - handler = noop - return error ? reject(error) : resolve(result) - }) - }).catch(error => { - if (failed) client.end(true) - return Promise.reject(error) - }) - }) - return client -} - -module.exports = redis From 316957a7313ea5c5ed81cab7407ebfb07d5f48aa Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 3 Jan 2018 17:01:13 -0500 Subject: [PATCH 09/62] No redis dependency --- .travis.yml | 1 - package.json | 1 - test/test.js | 6 +++--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3658d14..39399f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,4 +6,3 @@ env: global: # - __OW_API_HOST="OpenWhisk host" # - __OW_API_KEY="OpenWhisk API key" -# - REDIS="Redis url" diff --git a/package.json b/package.json index b5e242d..6639e09 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ ], "dependencies": { "openwhisk": "^3.11.0", - "redis": "^2.8.0", "clone": "^2.1.1" }, "devDependencies": { diff --git a/test/test.js b/test/test.js index d7f255c..b7ba1ab 100644 --- a/test/test.js +++ b/test/test.js @@ -10,9 +10,9 @@ describe('composer', function () { before('deploy conductor and sample actions', function () { return composer.deploy( [{ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' }, - { name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' }, - { name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' }, - { name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' }]) + { name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' }, + { name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' }, + { name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' }]) }) describe('blocking invocations', function () { From 405d56542df87d7e5a03448a254faa5b61fdb279 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 8 Jan 2018 13:08:15 -0500 Subject: [PATCH 10/62] Fixes and tweaks --- composer.js | 70 ++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/composer.js b/composer.js index 904b10a..0cd2d41 100644 --- a/composer.js +++ b/composer.js @@ -18,11 +18,12 @@ // composer module -const clone = require('clone') -const util = require('util') const fs = require('fs') -const path = require('path') const os = require('os') +const path = require('path') +const util = require('util') + +const clone = require('clone') const openwhisk = require('openwhisk') class ComposerError extends Error { @@ -61,11 +62,11 @@ function end(id) { return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } -const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) +const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) class Composer { constructor(options = {}) { - // try to extract apihost and key + // try to extract apihost and key from wskprops let apihost let api_key @@ -91,19 +92,22 @@ class Composer { task(obj, options) { if (options != null && options.output) return this.assign(options.output, obj, options.input) if (options != null && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) - const id = {} let Entry let Manifest = [] if (obj == null) { // identity function (must throw errors if any) - Entry = { Type: 'Task', Helper: 'null', Function: 'params => params', id } - } else if (typeof obj === 'object' && typeof obj.Entry === 'object' && Array.isArray(obj.States) && typeof obj.Exit === 'object') { // an action composition + Entry = { Type: 'Task', Helper: 'null', Function: 'params => params' } + } else if (Array.isArray(obj)) { + Entry = { Type: 'Task', Action: obj.slice(-1)[0].name } + Manifest = clone(obj) + } else if (typeof obj === 'object' && typeof obj.Entry === 'object' + && Array.isArray(obj.States) && typeof obj.Exit === 'object' && Array.isArray(obj.Manifest)) { // an action composition return clone(obj) } else if (typeof obj === 'function') { // function - Entry = { Type: 'Task', Function: obj.toString(), id } + Entry = { Type: 'Task', Function: obj.toString() } } else if (typeof obj === 'string') { // action - Entry = { Type: 'Task', Action: obj, id } - } else if (typeof obj === 'object' && typeof obj.Helper !== 'undefined' && typeof obj.Function === 'string') { //helper function - Entry = { Type: 'Task', Function: obj.Function, Helper: obj.Helper, id } + Entry = { Type: 'Task', Action: obj } + } else if (typeof obj === 'object' && typeof obj.Helper === 'string' && typeof obj.Function === 'string') { // helper function + Entry = { Type: 'Task', Function: obj.Function, Helper: obj.Helper } } else { // error throw new ComposerError('Invalid composition argument', obj) } @@ -111,9 +115,9 @@ class Composer { } taskFromFile(name, filename) { - if (typeof name !== 'string') throw new ComposerError('Invalid name argument in taskFromFile', name) - if (typeof filename !== 'string') throw new ComposerError('Invalid filename argument in taskFromFile', filename) - const Entry = { Type: 'Task', Action: name, id: {} } + if (typeof name !== 'string') throw new ComposerError('Invalid name argument for taskFromFile', name) + if (typeof filename !== 'string') throw new ComposerError('Invalid filename argument for taskFromFile', filename) + const Entry = { Type: 'Task', Action: name } return { Entry, States: [Entry], Exit: Entry, Manifest: [{ name, action: fs.readFileSync(filename, { encoding: 'utf8' }) }] } } @@ -142,6 +146,8 @@ class Composer { alternate.Exit.Next = Exit test.States.push(Exit) test.Exit = Exit + test.Manifest.push(...consequent.Manifest) + test.Manifest.push(...alternate.Manifest) return test } @@ -158,6 +164,7 @@ class Composer { body.Exit.Next = test.Entry test.States.push(Exit) test.Exit = Exit + test.Manifest.push(...body.Manifest) return test } @@ -258,27 +265,22 @@ class Composer { } value(json) { - const id = {} if (typeof json === 'function') throw new ComposerError('Value cannot be a function', json.toString()) - const Entry = { Type: 'Task', Value: typeof json === 'undefined' ? {} : json, id } + const Entry = { Type: 'Task', Value: typeof json === 'undefined' ? {} : json } return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } compile(name, obj, filename) { - if (typeof name !== 'string') throw new ComposerError('Invalid name argument in compile', name) - if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid optional filename argument in compile', filename) - if (typeof obj !== 'object' || typeof obj.Entry !== 'object' || !Array.isArray(obj.States) || typeof obj.Exit !== 'object') { - throw new ComposerError('Invalid argument to compile', obj) - } - obj = clone(obj) + if (typeof name !== 'string') throw new ComposerError('Invalid name argument for compile', name) + if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid optional filename argument for compile', filename) + obj = this.task(obj) const States = {} let Entry let Exit - let Count = 0 - obj.States.forEach(state => { - if (typeof state.id.id === 'undefined') state.id.id = Count++ - }) + let count = 0 obj.States.forEach(state => { + if (typeof state.id === 'undefined') state.id = {} + if (typeof state.id.id === 'undefined') state.id.id = count++ const id = (state.Type === 'Task' ? state.Action && 'action' || state.Function && 'function' || state.Value && 'value' : state.Type.toLowerCase()) + '_' + state.id.id States[id] = state state.id = id @@ -296,19 +298,17 @@ class Composer { }) const action = `${main}\nconst __composition__ = ${JSON.stringify({ Entry, States, Exit }, null, 4)}\n` if (filename) fs.writeFileSync(filename, action, { encoding: 'utf8' }) - const app = this.task(name) - app.Manifest = clone(obj.Manifest) - app.Manifest.push({ name, action, annotations: { conductor: { Entry, States, Exit } } }) - return app + obj.Manifest.push({ name, action, annotations: { conductor: { Entry, States, Exit } } }) + return obj.Manifest } deploy(obj) { - if (typeof obj === 'object' && Array.isArray(obj.Manifest)) obj = obj.Manifest - if (!Array.isArray(obj) || obj.length === 0) throw new ComposerError('Invalid argument to deploy', obj) + if (!Array.isArray(obj) || obj.length === 0) throw new ComposerError('Invalid argument for deploy', obj) // return the count of successfully deployed actions return clone(obj).reduce( - (promise, action) => promise.then(i => this.wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0)).then(i => `${i}/${obj.length}`) + (promise, action) => promise.then(i => this.wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0) + ).then(i => `${i}/${obj.length}`) } } @@ -334,7 +334,7 @@ function main(params) { // keep outer namespace clean return (() => { - const isObject = obj => typeof (obj) === 'object' && obj !== null && !Array.isArray(obj) + const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) // encode error object const encodeError = error => ({ From 5e0c411a28518157c013e67ce55b64bc211cc934 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 23 Jan 2018 15:01:41 -0500 Subject: [PATCH 11/62] Check array size and element type --- composer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.js b/composer.js index 0cd2d41..c4940ec 100644 --- a/composer.js +++ b/composer.js @@ -96,7 +96,7 @@ class Composer { let Manifest = [] if (obj == null) { // identity function (must throw errors if any) Entry = { Type: 'Task', Helper: 'null', Function: 'params => params' } - } else if (Array.isArray(obj)) { + } else if (Array.isArray(obj) && obj.length > 0 && typeof obj.slice(-1)[0].name === 'string') { Entry = { Type: 'Task', Action: obj.slice(-1)[0].name } Manifest = clone(obj) } else if (typeof obj === 'object' && typeof obj.Entry === 'object' From 89a9aade5c0d2a7942e84417b575b0748963eb9b Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 24 Jan 2018 10:33:03 -0500 Subject: [PATCH 12/62] Handle helper tags like other options --- composer.js | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/composer.js b/composer.js index c4940ec..5a62680 100644 --- a/composer.js +++ b/composer.js @@ -90,8 +90,8 @@ class Composer { } task(obj, options) { - if (options != null && options.output) return this.assign(options.output, obj, options.input) - if (options != null && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) + if (options && options.output) return this.assign(options.output, obj, options.input) + if (options && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) let Entry let Manifest = [] if (obj == null) { // identity function (must throw errors if any) @@ -106,11 +106,10 @@ class Composer { Entry = { Type: 'Task', Function: obj.toString() } } else if (typeof obj === 'string') { // action Entry = { Type: 'Task', Action: obj } - } else if (typeof obj === 'object' && typeof obj.Helper === 'string' && typeof obj.Function === 'string') { // helper function - Entry = { Type: 'Task', Function: obj.Function, Helper: obj.Helper } } else { // error throw new ComposerError('Invalid composition argument', obj) } + if (options && options.Helper) Entry.Helper = options.Helper return { Entry, States: [Entry], Exit: Entry, Manifest } } @@ -191,21 +190,17 @@ class Composer { const id = {} if (!flag) return chain(push(id), chain(this.task(body), pop(id))) - let helperFunc_1 = { 'Helper': 'retain_1', 'Function': 'params => ({params})' } - let helperFunc_3 = { 'Helper': 'retain_3', 'Function': 'params => ({params})' } - let helperFunc_2 = { 'Helper': 'retain_2', 'Function': 'params => ({ params: params.params, result: params.result.params })' } - return this.sequence( this.retain( this.try( this.sequence( body, - helperFunc_1 + this.task(params => ({ params }), { Helper: 'retain_1' }) ), - helperFunc_3 + this.task(params => ({ params }), { Helper: 'retain_3' }) ) ), - helperFunc_2 + this.task(params => ({ params: params.params, result: params.result.params }), { Helper: 'retain_2' }) ) } @@ -213,11 +208,8 @@ class Composer { if (dest == null || body == null) throw new ComposerError('Missing arguments in composition', arguments) if (typeof flag !== 'boolean') throw new ComposerError('Invalid assign flag', flag) - let helperFunc_1 = { 'Helper': 'assign_1', 'Function': 'params => params[source]' }; - let helperFunc_2 = { 'Helper': 'assign_2', 'Function': 'params => { params.params[dest] = params.result; return params.params }' }; - - const t = source ? this.let('source', source, this.retain(this.sequence(helperFunc_1, body), flag)) : this.retain(body, flag) - return this.let('dest', dest, t, helperFunc_2) + const t = source ? this.let('source', source, this.retain(this.sequence(this.task(params => params[source], { Helper: 'assign_1' }), body), flag)) : this.retain(body, flag) + return this.let('dest', dest, t, this.task(params => { params.params[dest] = params.result; return params.params }, { Helper: 'assign_2' })) } let(arg1, arg2) { @@ -244,24 +236,19 @@ class Composer { if (body == null) throw new ComposerError('Missing arguments in composition', arguments) if (typeof count !== 'number') throw new ComposerError('Invalid retry count', count) - let helperFunc_1 = { 'Helper': 'retry_1', 'Function': "params => typeof params.result.error !== 'undefined' && count-- > 0" } - let helperFunc_2 = { 'Helper': 'retry_2', 'Function': 'params => params.params' } - let helperFunc_3 = { 'Helper': 'retry_3', 'Function': 'params => params.result' } - return this.let('count', count, this.retain(body, true), this.while( - helperFunc_1, - this.sequence(helperFunc_2, this.retain(body, true))), - helperFunc_3) + this.task(params => typeof params.result.error !== 'undefined' && count-- > 0, { Helper: 'retry_1' }), + this.sequence(this.task(params => params.params, { Helper: 'retry_2' }), this.retain(body, true))), + this.task(params => params.result, { Helper: 'retry_3' })) } repeat(count, body) { if (body == null) throw new ComposerError('Missing arguments in composition', arguments) if (typeof count !== 'number') throw new ComposerError('Invalid repeat count', count) - let helperFunc_1 = { 'Helper': 'repeat_1', 'Function': '() => count-- > 0' } - return this.let('count', count, this.while(helperFunc_1, body)) + return this.let('count', count, this.while(this.task(() => count-- > 0, { Helper: 'repeat_1' }), body)) } value(json) { From 278b3753a36322eeb7e5c15f9ca59128d923e3ad Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 24 Jan 2018 11:32:06 -0500 Subject: [PATCH 13/62] Fold taskFromFile as an option --- composer.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/composer.js b/composer.js index 5a62680..664c99b 100644 --- a/composer.js +++ b/composer.js @@ -94,32 +94,33 @@ class Composer { if (options && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) let Entry let Manifest = [] - if (obj == null) { // identity function (must throw errors if any) - Entry = { Type: 'Task', Helper: 'null', Function: 'params => params' } + if (obj == null) { + // case null: identity function (must throw errors if any) + return this.task(params => params, { Helper: 'null' }) } else if (Array.isArray(obj) && obj.length > 0 && typeof obj.slice(-1)[0].name === 'string') { - Entry = { Type: 'Task', Action: obj.slice(-1)[0].name } + // case array: last action in the array Manifest = clone(obj) - } else if (typeof obj === 'object' && typeof obj.Entry === 'object' - && Array.isArray(obj.States) && typeof obj.Exit === 'object' && Array.isArray(obj.Manifest)) { // an action composition + Entry = { Type: 'Task', Action: obj.slice(-1)[0].name } + } else if (typeof obj === 'object' && typeof obj.Entry === 'object' && Array.isArray(obj.States) && typeof obj.Exit === 'object' && Array.isArray(obj.Manifest)) { + // case object: composition return clone(obj) - } else if (typeof obj === 'function') { // function + } else if (typeof obj === 'function') { + // case function: inline function Entry = { Type: 'Task', Function: obj.toString() } - } else if (typeof obj === 'string') { // action + } else if (typeof obj === 'string') { + // case string: action + if (options && options.filename) Manifest = [{ name: obj, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] + if (options && typeof options.action === 'string') Manifest = [{ name: obj, action: options.action }] + if (options && typeof options.action === 'function') Manifest = [{ name: obj, action: options.action.toString() }] Entry = { Type: 'Task', Action: obj } - } else { // error + } else { + // error throw new ComposerError('Invalid composition argument', obj) } if (options && options.Helper) Entry.Helper = options.Helper return { Entry, States: [Entry], Exit: Entry, Manifest } } - taskFromFile(name, filename) { - if (typeof name !== 'string') throw new ComposerError('Invalid name argument for taskFromFile', name) - if (typeof filename !== 'string') throw new ComposerError('Invalid filename argument for taskFromFile', filename) - const Entry = { Type: 'Task', Action: name } - return { Entry, States: [Entry], Exit: Entry, Manifest: [{ name, action: fs.readFileSync(filename, { encoding: 'utf8' }) }] } - } - sequence() { if (arguments.length == 0) return this.task() return Array.prototype.map.call(arguments, x => this.task(x), this).reduce(chain) From d2a54c62fff1471ca56edfcbf2300748e7908357 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Fri, 26 Jan 2018 11:07:31 -0500 Subject: [PATCH 14/62] Implement pre and postprocessing --- composer.js | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/composer.js b/composer.js index 664c99b..7e8970d 100644 --- a/composer.js +++ b/composer.js @@ -87,11 +87,12 @@ class Composer { } catch (error) { } this.wsk = openwhisk(Object.assign({ apihost, api_key }, options)) + this.merge = (result, params) => Object.assign(params, result) } task(obj, options) { - if (options && options.output) return this.assign(options.output, obj, options.input) - if (options && options.merge) return this.sequence(this.retain(obj), ({ params, result }) => Object.assign({}, params, result)) + if (options && options.pre) return this.preprocess(obj, options) + if (options && options.post) return this.postprocess(obj, options) let Entry let Manifest = [] if (obj == null) { @@ -121,6 +122,31 @@ class Composer { return { Entry, States: [Entry], Exit: Entry, Manifest } } + + preprocess(obj, options) { + const pre = options.pre.toString() + return this.sequence(this.retain(this.value({ pre })), ({ params, result }) => { + const dict = eval(result.pre)(params) + return typeof dict === 'undefined' ? params : dict + }, this.task(obj, Object.assign({}, options, { pre: undefined }))) + } + + postprocess(obj, options) { + const post = options.post.toString() + if (options.post.length === 1) { + return this.sequence(this.task(obj, Object.assign({}, options, { post: undefined })), this.retain(this.value({ post })), ({ params, result }) => { + const dict = eval(result.post)(params) + return typeof dict === 'undefined' ? params : dict + }) + } else { + // save params + return this.sequence(this.retain(this.task(obj, Object.assign({}, options, { post: undefined }))), this.retain(this.value({ post })), ({ params, result }) => { + const dict = eval(result.post)(params.result, params.params) + return typeof dict === 'undefined' ? params.result : dict + }) + } + } + sequence() { if (arguments.length == 0) return this.task() return Array.prototype.map.call(arguments, x => this.task(x), this).reduce(chain) @@ -253,7 +279,6 @@ class Composer { } value(json) { - if (typeof json === 'function') throw new ComposerError('Value cannot be a function', json.toString()) const Entry = { Type: 'Task', Value: typeof json === 'undefined' ? {} : json } return { Entry, States: [Entry], Exit: Entry, Manifest: [] } } @@ -261,6 +286,7 @@ class Composer { compile(name, obj, filename) { if (typeof name !== 'string') throw new ComposerError('Invalid name argument for compile', name) if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid optional filename argument for compile', filename) + if (typeof obj === 'undefied') new ComposerError('Missing arguments in composition', arguments) obj = this.task(obj) const States = {} let Entry From 7641d2311c1a03b6f61733ef1f7fd99e5efaa45a Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 30 Jan 2018 13:51:18 -0500 Subject: [PATCH 15/62] Major rewrite --- composer.js | 628 +++++++++++++++++++++++---------------------------- test/test.js | 83 +++++-- 2 files changed, 343 insertions(+), 368 deletions(-) diff --git a/composer.js b/composer.js index 7e8970d..deeeb55 100644 --- a/composer.js +++ b/composer.js @@ -1,5 +1,5 @@ /* - * Copyright 2017 IBM Corporation + * Copyright 2017-2018 IBM Corporation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,6 @@ const fs = require('fs') const os = require('os') const path = require('path') const util = require('util') - const clone = require('clone') const openwhisk = require('openwhisk') @@ -34,36 +33,27 @@ class ComposerError extends Error { } } +// build a sequence of front and back, mutating front function chain(front, back) { - front.States.push(...back.States) - front.Exit.Next = back.Entry - front.Exit = back.Exit + front.states.push(...back.states) + front.exit.next = back.entry + front.exit = back.exit front.Manifest.push(...back.Manifest) return front } -function push(id) { - const Entry = { Type: 'Push', id } - return { Entry, States: [Entry], Exit: Entry, Manifest: [] } -} - -function pop(id) { - const Entry = { Type: 'Pop', id } - return { Entry, States: [Entry], Exit: Entry, Manifest: [] } +// composition with one push state +function push(id, op) { + const entry = { type: 'push', id, op } + return { entry, states: [entry], exit: entry, Manifest: [] } } -function begin(id, symbol, value) { - const Entry = { Type: 'Let', Symbol: symbol, Value: value, id } - return { Entry, States: [Entry], Exit: Entry, Manifest: [] } +// composition with one pop state +function pop(id, op, branch) { + const entry = { type: 'pop', id, op, branch } + return { entry, states: [entry], exit: entry, Manifest: [] } } -function end(id) { - const Entry = { Type: 'End', id } - return { Entry, States: [Entry], Exit: Entry, Manifest: [] } -} - -const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) - class Composer { constructor(options = {}) { // try to extract apihost and key from wskprops @@ -87,242 +77,210 @@ class Composer { } catch (error) { } this.wsk = openwhisk(Object.assign({ apihost, api_key }, options)) - this.merge = (result, params) => Object.assign(params, result) + this.seq = this.sequence } - task(obj, options) { - if (options && options.pre) return this.preprocess(obj, options) - if (options && options.post) return this.postprocess(obj, options) - let Entry - let Manifest = [] + task(obj) { + if (arguments.length > 1) throw new Error('Too many arguments') if (obj == null) { // case null: identity function (must throw errors if any) - return this.task(params => params, { Helper: 'null' }) + return this.function(params => params, { helper: 'null' }) } else if (Array.isArray(obj) && obj.length > 0 && typeof obj.slice(-1)[0].name === 'string') { // case array: last action in the array - Manifest = clone(obj) - Entry = { Type: 'Task', Action: obj.slice(-1)[0].name } - } else if (typeof obj === 'object' && typeof obj.Entry === 'object' && Array.isArray(obj.States) && typeof obj.Exit === 'object' && Array.isArray(obj.Manifest)) { + const Manifest = clone(obj) + const entry = { type: 'action', action: obj.slice(-1)[0].name } + return { entry, states: [entry], exit: entry, Manifest } + } else if (typeof obj === 'object' && typeof obj.entry === 'object' && Array.isArray(obj.states) && typeof obj.exit === 'object' && Array.isArray(obj.Manifest)) { // case object: composition return clone(obj) } else if (typeof obj === 'function') { // case function: inline function - Entry = { Type: 'Task', Function: obj.toString() } + return this.function(obj) } else if (typeof obj === 'string') { // case string: action - if (options && options.filename) Manifest = [{ name: obj, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] - if (options && typeof options.action === 'string') Manifest = [{ name: obj, action: options.action }] - if (options && typeof options.action === 'function') Manifest = [{ name: obj, action: options.action.toString() }] - Entry = { Type: 'Task', Action: obj } + return this.action(obj) } else { // error - throw new ComposerError('Invalid composition argument', obj) - } - if (options && options.Helper) Entry.Helper = options.Helper - return { Entry, States: [Entry], Exit: Entry, Manifest } - } - - - preprocess(obj, options) { - const pre = options.pre.toString() - return this.sequence(this.retain(this.value({ pre })), ({ params, result }) => { - const dict = eval(result.pre)(params) - return typeof dict === 'undefined' ? params : dict - }, this.task(obj, Object.assign({}, options, { pre: undefined }))) - } - - postprocess(obj, options) { - const post = options.post.toString() - if (options.post.length === 1) { - return this.sequence(this.task(obj, Object.assign({}, options, { post: undefined })), this.retain(this.value({ post })), ({ params, result }) => { - const dict = eval(result.post)(params) - return typeof dict === 'undefined' ? params : dict - }) - } else { - // save params - return this.sequence(this.retain(this.task(obj, Object.assign({}, options, { post: undefined }))), this.retain(this.value({ post })), ({ params, result }) => { - const dict = eval(result.post)(params.result, params.params) - return typeof dict === 'undefined' ? params.result : dict - }) + throw new ComposerError('Invalid argument', obj) } } - sequence() { + sequence() { // varargs if (arguments.length == 0) return this.task() return Array.prototype.map.call(arguments, x => this.task(x), this).reduce(chain) } - seq() { - return this.sequence(...arguments) - } - if(test, consequent, alternate) { - if (test == null || consequent == null) throw new ComposerError('Missing arguments in composition', arguments) + if (arguments.length > 3) throw new Error('Too many arguments') const id = {} test = chain(push(id), this.task(test)) - consequent = this.task(consequent) - alternate = this.task(alternate) - const Exit = { Type: 'Pass', id } - const choice = { Type: 'Choice', Then: consequent.Entry, Else: alternate.Entry, id } - test.States.push(choice) - test.States.push(...consequent.States) - test.States.push(...alternate.States) - test.Exit.Next = choice - consequent.Exit.Next = Exit - alternate.Exit.Next = Exit - test.States.push(Exit) - test.Exit = Exit + consequent = chain(pop(id, 'pop', 'then'), this.task(consequent)) + alternate = chain(pop(id, 'pop', 'else'), this.task(alternate)) + const exit = { type: 'pass', id } + const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } + test.states.push(choice) + test.states.push(...consequent.states) + test.states.push(...alternate.states) + test.states.push(exit) + test.exit.next = choice + consequent.exit.next = exit + alternate.exit.next = exit + test.exit = exit test.Manifest.push(...consequent.Manifest) test.Manifest.push(...alternate.Manifest) return test } while(test, body) { - if (test == null || body == null) throw new ComposerError('Missing arguments in composition', arguments) + if (arguments.length > 2) throw new Error('Too many arguments') const id = {} test = chain(push(id), this.task(test)) - body = this.task(body) - const Exit = { Type: 'Pass', id } - const choice = { Type: 'Choice', Then: body.Entry, Else: Exit, id } - test.States.push(choice) - test.States.push(...body.States) - test.Exit.Next = choice - body.Exit.Next = test.Entry - test.States.push(Exit) - test.Exit = Exit - test.Manifest.push(...body.Manifest) + const consequent = chain(pop(id, 'pop', 'then'), this.task(body)) + const alternate = pop(id, 'pop', 'else') + const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } + test.states.push(choice) + test.states.push(...consequent.states) + test.states.push(...alternate.states) + test.exit.next = choice + consequent.exit.next = test.entry + test.exit = alternate.exit + test.Manifest.push(...consequent.Manifest) return test } try(body, handler) { - if (body == null || handler == null) throw new ComposerError('Missing arguments in composition', arguments) + if (arguments.length > 2) throw new Error('Too many arguments') const id = {} - body = this.task(body) handler = this.task(handler) - const Exit = { Type: 'Pass', id } - const Entry = { Type: 'Try', Next: body.Entry, Handler: handler.Entry, id } - const pop = { Type: 'Catch', Next: Exit, id } - const States = [Entry] - States.push(...body.States, pop, ...handler.States, Exit) - body.Exit.Next = pop - handler.Exit.Next = Exit + body = chain(push(id, { catch: handler.entry }), chain(this.task(body), pop(id))) + const exit = { type: 'pass', id } + body.states.push(...handler.states) + body.states.push(exit) + body.exit.next = exit + handler.exit.next = exit + body.exit = exit body.Manifest.push(...handler.Manifest) - return { Entry, States, Exit, Manifest: body.Manifest } + return body } - retain(body, flag = false) { - if (body == null) throw new ComposerError('Missing arguments in composition', arguments) - if (typeof flag !== 'boolean') throw new ComposerError('Invalid retain flag', flag) + finally(body, handler) { + if (arguments.length > 2) throw new Error('Too many arguments') + const id = {} + handler = this.task(handler) + body = chain(push(id, { catch: handler.entry }), chain(this.task(body), pop(id))) + body.states.push(...handler.states) + body.exit.next = handler.entry + body.exit = handler.exit + body.Manifest.push(...handler.Manifest) + return body + } + let(obj) { // varargs + if (typeof obj !== 'object' || obj === null) throw new ComposerError('Invalid argument', obj) const id = {} - if (!flag) return chain(push(id), chain(this.task(body), pop(id))) - - return this.sequence( - this.retain( - this.try( - this.sequence( - body, - this.task(params => ({ params }), { Helper: 'retain_1' }) - ), - this.task(params => ({ params }), { Helper: 'retain_3' }) - ) - ), - this.task(params => ({ params: params.params, result: params.result.params }), { Helper: 'retain_2' }) - ) + return chain(push(id, { let: JSON.parse(JSON.stringify(obj)) }), chain(this.sequence(...Array.prototype.slice.call(arguments, 1)), pop(id))) } - assign(dest, body, source, flag = false) { - if (dest == null || body == null) throw new ComposerError('Missing arguments in composition', arguments) - if (typeof flag !== 'boolean') throw new ComposerError('Invalid assign flag', flag) + value(v) { + if (arguments.length > 1) throw new Error('Too many arguments') + if (typeof v === 'function') throw new ComposerError('Invalid argument', v) + const entry = { type: 'value', value: typeof v === 'undefined' ? {} : JSON.parse(JSON.stringify(v)) } + return { entry, states: [entry], exit: entry, Manifest: [] } + } + + function(f, options) { + if (arguments.length > 2) throw new Error('Too many arguments') + if (typeof f === 'function') f = `${f}` + if (typeof f !== 'string') throw new ComposerError('Invalid argument', f) + const entry = { type: 'function', function: f } + if (options && options.helper) entry.Helper = options.helper + return { entry, states: [entry], exit: entry, Manifest: [] } + } - const t = source ? this.let('source', source, this.retain(this.sequence(this.task(params => params[source], { Helper: 'assign_1' }), body), flag)) : this.retain(body, flag) - return this.let('dest', dest, t, this.task(params => { params.params[dest] = params.result; return params.params }, { Helper: 'assign_2' })) + action(action, options) { + if (arguments.length > 2) throw new Error('Too many arguments') + if (typeof action !== 'string') throw new ComposerError('Invalid argument', f) + let Manifest = [] + if (options && options.filename) Manifest = [{ name: obj, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] + if (options && typeof options.action === 'string') Manifest = [{ name: obj, action: options.action }] + if (options && typeof options.action === 'function') Manifest = [{ name: obj, action: `${options.action}` }] + const entry = { type: 'action', action } + return { entry, states: [entry], exit: entry, Manifest } } - let(arg1, arg2) { - if (arg1 == null) throw new ComposerError('Missing arguments in composition', arguments) - if (typeof arg1 === 'string') { + retain(body, options) { + if (arguments.length > 2) throw new Error('Too many arguments') + if (typeof options === 'undefined' || typeof options === 'string' || options === false) { const id = {} - return chain(begin(id, arg1, arg2), chain(this.sequence(...Array.prototype.slice.call(arguments, 2)), end(id))) - } else if (isObject(arg1)) { - const enter = [] - const exit = [] - for (const name in arg1) { - const id = {} - enter.push(begin(id, name, arg1[name])) - exit.unshift(end(id)) - } - if (enter.length == 0) return this.sequence(...Array.prototype.slice.call(arguments, 1)) - return chain(enter.reduce(chain), chain(this.sequence(...Array.prototype.slice.call(arguments, 1)), exit.reduce(chain))) + return chain(push(id, options), chain(this.task(body), pop(id, 'collect'))) + } else if (options === true) { + return this.sequence( + this.retain( + this.try( + this.sequence(body, this.function(result => ({ result }), { helper: 'retain_1' })), + this.function(result => ({ result }), { helper: 'retain_3' }))), + this.function(({ params, result }) => ({ params, result: result.result }), { helper: 'retain_2' })) + } else if (typeof options === 'function') { + return this.sequence(this.retain(options), this.retain(this.finally(this.function(({ params }) => params, { helper: 'retain_4' }), body), 'result')) } else { - throw new ComposerError('Invalid first let argument', arg1) + throw new ComposerError('Invalid argument', options) } } - retry(count, body) { - if (body == null) throw new ComposerError('Missing arguments in composition', arguments) - if (typeof count !== 'number') throw new ComposerError('Invalid retry count', count) - - return this.let('count', count, - this.retain(body, true), - this.while( - this.task(params => typeof params.result.error !== 'undefined' && count-- > 0, { Helper: 'retry_1' }), - this.sequence(this.task(params => params.params, { Helper: 'retry_2' }), this.retain(body, true))), - this.task(params => params.result, { Helper: 'retry_3' })) + repeat(count) { // varargs + if (typeof count !== 'number') throw new ComposerError('Invalid argument', count) + return this.let({ count }, this.while(this.function(() => count-- > 0, { helper: 'repeat_1' }), this.sequence(...Array.prototype.slice.call(arguments, 1)))) } - repeat(count, body) { - if (body == null) throw new ComposerError('Missing arguments in composition', arguments) - if (typeof count !== 'number') throw new ComposerError('Invalid repeat count', count) - - return this.let('count', count, this.while(this.task(() => count-- > 0, { Helper: 'repeat_1' }), body)) - } - - value(json) { - const Entry = { Type: 'Task', Value: typeof json === 'undefined' ? {} : json } - return { Entry, States: [Entry], Exit: Entry, Manifest: [] } + retry(count) { // varargs + if (typeof count !== 'number') throw new ComposerError('Invalid argument', count) + const attempt = this.retain(this.sequence(...Array.prototype.slice.call(arguments, 1)), true) + return this.let({ count }, + attempt, + this.while( + this.function(({ result }) => typeof result.error !== 'undefined' && count-- > 0, { helper: 'retry_1' }), + this.finally(this.function(({ params }) => params, { helper: 'retry_2' }), attempt)), + this.function(({ result }) => result, { helper: 'retry_3' })) } + // produce action code compile(name, obj, filename) { - if (typeof name !== 'string') throw new ComposerError('Invalid name argument for compile', name) - if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid optional filename argument for compile', filename) - if (typeof obj === 'undefied') new ComposerError('Missing arguments in composition', arguments) + if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) + if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid argument', filename) obj = this.task(obj) - const States = {} - let Entry - let Exit + const states = {} + let entry + let exit let count = 0 - obj.States.forEach(state => { + obj.states.forEach(state => { if (typeof state.id === 'undefined') state.id = {} if (typeof state.id.id === 'undefined') state.id.id = count++ - const id = (state.Type === 'Task' ? state.Action && 'action' || state.Function && 'function' || state.Value && 'value' : state.Type.toLowerCase()) + '_' + state.id.id - States[id] = state + const id = state.type + '_' + (state.branch ? state.branch + '_' : '') + state.id.id + states[id] = state state.id = id - if (state === obj.Entry) Entry = id - if (state === obj.Exit) Exit = id + if (state === obj.entry) entry = id + if (state === obj.exit) exit = id }) - obj.States.forEach(state => { - if (state.Next) state.Next = state.Next.id - if (state.Then) state.Then = state.Then.id - if (state.Else) state.Else = state.Else.id - if (state.Handler) state.Handler = state.Handler.id + obj.states.forEach(state => { + if (state.next) state.next = state.next.id + if (state.then) state.then = state.then.id + if (state.else) state.else = state.else.id + if (state.op && state.op.catch) state.op.catch = state.op.catch.id }) - obj.States.forEach(state => { - delete state.id - }) - const action = `${main}\nconst __composition__ = ${JSON.stringify({ Entry, States, Exit }, null, 4)}\n` + obj.states.forEach(state => delete state.id) + const composition = { entry, states, exit } + const action = `const __eval__ = main => eval(main)\n${main}\nconst __composition__ = ${JSON.stringify(composition, null, 4)}\n` if (filename) fs.writeFileSync(filename, action, { encoding: 'utf8' }) - obj.Manifest.push({ name, action, annotations: { conductor: { Entry, States, Exit } } }) + obj.Manifest.push({ name, action, annotations: { conductor: composition } }) return obj.Manifest } - deploy(obj) { - if (!Array.isArray(obj) || obj.length === 0) throw new ComposerError('Invalid argument for deploy', obj) - - // return the count of successfully deployed actions - return clone(obj).reduce( - (promise, action) => promise.then(i => this.wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0) - ).then(i => `${i}/${obj.length}`) + // deploy actions, return count of successfully deployed actions + deploy(actions) { + if (!Array.isArray(actions)) throw new ComposerError('Invalid argument', array) + // clone array as openwhisk mutates the actions + return clone(actions).reduce( + (promise, action) => promise.then(i => this.wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0)) } } @@ -331,169 +289,151 @@ module.exports = new Composer() // conductor action function main(params) { - // evaluate main exposing the fields of __env__ as variables - function __eval__(__env__, main) { - main = `(${main})` - let __eval__ = '__eval__=undefined;params=>{try{' - for (const name in __env__) { - __eval__ += `var ${name}=__env__['${name}'];` - } - __eval__ += 'return eval(main)(params)}finally{' - for (const name in __env__) { - __eval__ += `__env__['${name}']=${name};` + const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) + + // encode error object + const encodeError = error => ({ + code: typeof error.code === 'number' && error.code || 500, + error: (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error) || 'An internal error occurred' + }) + + // error status codes + const badRequest = error => Promise.reject({ code: 400, error }) + const internalError = error => Promise.reject(encodeError(error)) + + // catch all + return Promise.resolve().then(() => invoke(params)).catch(internalError) + + // do invocation + function invoke(params) { + // initial state and stack + let state = __composition__.entry + let stack = [] + + // check parameters + if (typeof __composition__.entry !== 'string') return badRequest('The composition has no entry field of type string') + if (!isObject(__composition__.states)) return badRequest('The composition has no states field of type object') + if (typeof __composition__.exit !== 'string') return badRequest('The composition has no exit field of type string') + + // restore state and stack when resuming + if (typeof params.$resume !== 'undefined') { + if (!isObject(params.$resume)) return badRequest('The type of optional $resume parameter must be object') + state = params.$resume.state + stack = params.$resume.stack + if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('The type of optional $resume.state parameter must be string') + if (!Array.isArray(stack)) return badRequest('The type of $resume.stack must be an array') + delete params.$resume + inspect() // handle error objects when resuming } - __eval__ += '}}' - return eval(__eval__) - } - - // keep outer namespace clean - return (() => { - const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) - - // encode error object - const encodeError = error => ({ - code: typeof error.code === 'number' && error.code || 500, - error: (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error) || 'An internal error occurred' - }) - // error status codes - const badRequest = error => Promise.reject({ code: 400, error }) - const internalError = error => Promise.reject(encodeError(error)) - - // catch all - return Promise.resolve().then(() => invoke(params)).catch(internalError) - - // do invocation - function invoke(params) { - const fsm = __composition__ - - if (typeof fsm.Entry !== 'undefined' && typeof fsm.Entry !== 'string') return badRequest('The type of Entry field of the composition must be string') - if (!isObject(fsm.States)) return badRequest('The composition has no States field of type object') - if (typeof fsm.Exit !== 'string') return badRequest('The composition has no Exit field of type string') - - let state = fsm.Entry - let stack = [] - - // check parameters - if (typeof params.$resume !== 'undefined') { - if (!isObject(params.$resume)) return badRequest('Type of optional $resume parameter must be object') - state = params.$resume.state - if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('Type of optional $resume.state parameter must be string') - stack = params.$resume.stack - if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('Type of optional $resume.state parameter must be string') - if (!Array.isArray(stack)) return badRequest('The type of $resume.stack must be an array') - delete params.$resume - inspect() // handle error objects when resuming - } - - // wrap params if not a JSON object, branch to error handler if error - function inspect() { - if (!isObject(params) || Array.isArray(params) || params === null) { - params = { value: params } - } - if (typeof params.error !== 'undefined') { - params = { error: params.error } // discard all fields but the error field - state = undefined // abort unless there is a handler in the stack - while (stack.length > 0) { - if (state = stack.shift().catch) break - } + // wrap params if not a dictionary, branch to error handler if error + function inspect() { + if (!isObject(params)) params = { value: params } + if (typeof params.error !== 'undefined') { + params = { error: params.error } // discard all fields but the error field + state = undefined // abort unless there is a handler in the stack + while (stack.length > 0) { + if (state = stack.shift().catch) break } } + } - // run function f on current stack - function run(f) { - function set(symbol, value) { - const element = stack.find(element => typeof element.let !== 'undefined' && typeof element.let[symbol] !== 'undefined') - if (typeof element !== 'undefined') element.let[symbol] = JSON.parse(JSON.stringify(value)) - } + // run function f on current stack + function run(f) { + // update value of topmost matching symbol on stack if any + function set(symbol, value) { + const element = stack.find(element => typeof element.let !== 'undefined' && typeof element.let[symbol] !== 'undefined') + if (typeof element !== 'undefined') element.let[symbol] = JSON.parse(JSON.stringify(value)) + } - const env = stack.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) - const result = __eval__(env, f)(params) - for (const name in env) { - set(name, env[name]) - } - return result + // collapse stack for invocation + const env = stack.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) + let main = '(function main(){try{' + for (const name in env) main += `var ${name}=arguments[1]['${name}'];` + main += `return eval((${f}))(arguments[0])}finally{` + for (const name in env) main += `arguments[1]['${name}']=${name};` + main += '}})' + try { + return __eval__(main)(params, env) + } finally { + for (const name in env) set(name, env[name]) } + } - while (true) { - // final state - if (!state) { - console.log(`Entering final state`) - console.log(JSON.stringify(params)) - if (params.error) return params; else return { params } - } + while (true) { + // final state, return composition result + if (!state) { + console.log(`Entering final state`) + console.log(JSON.stringify(params)) + if (params.error) return params; else return { params } + } - console.log(`Entering ${state}`) - - if (!isObject(fsm.States[state])) return badRequest(`The composition has no state named ${state}`) - const json = fsm.States[state] // json for current state - if (json.Type !== 'Choice' && typeof json.Next !== 'string' && state !== fsm.Exit) return badRequest(`The state named ${state} has no Next field`) - const current = state // current state - state = json.Next // default next state - - switch (json.Type) { - case 'Choice': - if (typeof json.Then !== 'string') return badRequest(`The state named ${current} of type Choice has no Then field`) - if (typeof json.Else !== 'string') return badRequest(`The state named ${current} of type Choice has no Else field`) - state = params.value === true ? json.Then : json.Else - if (stack.length === 0) return badRequest(`The state named ${current} of type Choice attempted to pop from an empty stack`) - const top = stack.shift() - if (typeof top.params !== 'object') return badRequest(`The state named ${current} of type Choice popped an unexpected stack element`) - params = top.params - break - case 'Try': - if (typeof json.Handler !== 'string') return badRequest(`The state named ${current} of type Try has no Handler field`) - stack.unshift({ catch: json.Handler }) // register handler - break - case 'Catch': - if (stack.length === 0) return badRequest(`The state named ${current} of type Catch attempted to pop from an empty stack`) - if (typeof stack.shift().catch !== 'string') return badRequest(`The state named ${current} of type Catch popped an unexpected stack element`) - break - case 'Push': - stack.unshift({ params: JSON.parse(JSON.stringify(params)) }) - break - case 'Pop': - if (stack.length === 0) return badRequest(`The state named ${current} of type Pop attempted to pop from an empty stack`) - const tip = stack.shift() - if (typeof tip.params !== 'object') return badRequest(`The state named ${current} of type Pop popped an unexpected stack element`) - params = { result: params, params: tip.params } // combine current params with persisted params popped from stack - break - case 'Let': - stack.unshift({ let: {} }) - if (typeof json.Symbol !== 'string') return badRequest(`The state named ${current} of type Let has no Symbol field`) - if (typeof json.Value === 'undefined') return badRequest(`The state named ${current} of type Let has no Value field`) - stack[0].let[json.Symbol] = JSON.parse(JSON.stringify(json.Value)) - break - case 'End': - if (stack.length === 0) return badRequest(`The state named ${current} of type End attempted to pop from an empty stack`) - if (typeof stack.shift().let !== 'object') return badRequest(`The state named ${current} of type End popped an unexpected stack element`) - break - case 'Task': - if (typeof json.Action === 'string') { - return { action: json.Action, params, state: { $resume: { state, stack } } } - } else if (typeof json.Value !== 'undefined') { // value - params = JSON.parse(JSON.stringify(json.Value)) - inspect() - } else if (typeof json.Function === 'string') { // function - let result - try { - result = run(json.Function) - } catch (error) { - console.error(error) - result = { error: 'An error has occurred: ' + error } - } - params = typeof result === 'undefined' ? {} : JSON.parse(JSON.stringify(result)) - inspect() - } else { - return badRequest(`The kind field of the state named ${current} of type Task is missing`) - } - break - case 'Pass': - break - default: - return badRequest(`The state named ${current} has an unknown type`) - } + // process one state + console.log(`Entering ${state}`) + + if (!isObject(__composition__.states[state])) return badRequest(`State ${state} definition is missing`) + const json = __composition__.states[state] // json definition for current state + const current = state // current state for error messages + if (json.type !== 'choice' && typeof json.next !== 'string' && state !== __composition__.exit) return badRequest(`State ${state} has no next field`) + state = json.next // default next state + + switch (json.type) { + case 'choice': + if (typeof json.then !== 'string') return badRequest(`State ${current} has no then field`) + if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) + state = params.value === true ? json.then : json.else + break + case 'push': + if (typeof json.op === 'string') { // push { params: params[op] } + stack.unshift(JSON.parse(JSON.stringify({ params: params[json.op] }))) + } else if (typeof json.op !== 'undefined') { // push op + stack.unshift(JSON.parse(JSON.stringify(json.op))) + } else { // push { params } + stack.unshift(JSON.parse(JSON.stringify({ params }))) + } + break + case 'pop': + if (stack.length === 0) return badRequest(`State ${current} attempted to pop from an empty stack`) + const top = stack.shift() + switch (json.op) { + case 'pop': + params = top.params + break + case 'collect': + params = { params: top.params, result: params } + break + default: + // drop + } + break + case 'action': + if (typeof json.action !== 'string') return badRequest(`State ${current} specifies an invalid action`) + return { action: json.action, params, state: { $resume: { state, stack } } } // invoke continuation + break + case 'value': + if (typeof json.value === 'undefined') return badRequest(`State ${current} specifies an invalid value`) + params = JSON.parse(JSON.stringify(json.value)) + inspect() + break + case 'function': + if (typeof json.function !== 'string') return badRequest(`State ${current} specifies an invalid function`) + let result + try { + result = run(json.function) + } catch (error) { + console.error(error) + result = { error: `An exception was caught at state ${current} (see log for details)` } + } + if (typeof result === 'function') result = { error: `State ${current} evaluated to a function` } + // if a function has only side effects and no return value, return params + params = JSON.parse(JSON.stringify(typeof result === 'undefined' ? params : result)) + inspect() + break + case 'pass': + break + default: + return badRequest(`State ${current} has an unknown type`) } } - })() + } } diff --git a/test/test.js b/test/test.js index b7ba1ab..1c17dc5 100644 --- a/test/test.js +++ b/test/test.js @@ -37,7 +37,7 @@ describe('composer', function () { }) it('function must fail', function () { - return invoke(composer.task(() => n)).then(() => assert.fail(), activation => assert.equal(activation.error.response.result.error, 'An error has occurred: ReferenceError: n is not defined')) + return invoke(composer.task(() => n)).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error.startsWith('An exception was caught at state'))) }) }) @@ -57,7 +57,7 @@ describe('composer', function () { invoke(composer.task(false)) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid composition argument') + assert.equal(error, 'Error: Invalid argument') } }) @@ -66,7 +66,7 @@ describe('composer', function () { invoke(composer.task(42)) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid composition argument') + assert.equal(error, 'Error: Invalid argument') } }) @@ -75,7 +75,7 @@ describe('composer', function () { invoke(composer.task({ foo: 'bar' })) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid composition argument') + assert.equal(error, 'Error: Invalid argument') } }) }) @@ -111,6 +111,16 @@ describe('composer', function () { }) describe('if', function () { + it('then branch no else branch', function () { + return invoke(composer.if('isEven', 'DivideByTwo'), { n: 4 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 2 })) + }) + + it('no else branch', function () { + return invoke(composer.if('isEven', 'DivideByTwo'), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 3 })) + }) + it('then branch', function () { return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 4 }) .then(activation => assert.deepEqual(activation.response.result, { n: 2 })) @@ -122,7 +132,6 @@ describe('composer', function () { }) }) - describe('while', function () { it('test 1', function () { return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) @@ -135,62 +144,88 @@ describe('composer', function () { }) }) - describe('retain', function () { + describe('try', function () { it('test 1', function () { - return invoke(composer.retain('TripleAndIncrement'), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) + return invoke(composer.try(() => true, error => ({ message: error.error }))) + .then(activation => assert.deepEqual(activation.response.result, { value: true })) }) - }) - describe('repeat', function () { - it('test 1', function () { - return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) + it('test 2', function () { + return invoke(composer.try(() => ({ error: 'foo' }), error => ({ message: error.error }))) + .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) }) - describe('try', function () { + describe('finally', function () { it('test 1', function () { - return invoke(composer.try(() => true, error => ({ message: error.error }))) - .then(activation => assert.deepEqual(activation.response.result, { value: true })) + return invoke(composer.finally(() => true, params => ({ params }))) + .then(activation => assert.deepEqual(activation.response.result, { params: { value: true } })) }) it('test 2', function () { - return invoke(composer.try(() => ({ error: 'foo' }), error => ({ message: error.error }))) - .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) + return invoke(composer.finally(() => ({ error: 'foo' }), params => ({ params }))) + .then(activation => assert.deepEqual(activation.response.result, { params: { error: 'foo' } })) }) }) describe('let', function () { it('one variable', function () { - return invoke(composer.let('x', 42, () => x)) + return invoke(composer.let({ x: 42 }, () => x)) .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) }) it('masking', function () { - return invoke(composer.let('x', 42, composer.let('x', 69, () => x))) + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x))) .then(activation => assert.deepEqual(activation.response.result, { value: 69 })) }) it('two variables', function () { - return invoke(composer.let('x', 42, composer.let('y', 69, () => x + y))) + return invoke(composer.let({ x: 42 }, composer.let({ y: 69 }, () => x + y))) + .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) + }) + + it('two variables combined', function () { + return invoke(composer.let({ x: 42, y: 69 }, () => x + y)) .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) }) it('scoping', function () { - return invoke(composer.let('x', 42, composer.let('x', 69, () => x), ({ value }) => value + x)) + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x), ({ value }) => value + x)) .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) }) }) + describe('retain', function () { + it('test 1', function () { + return invoke(composer.retain('TripleAndIncrement'), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) + }) + + it('test 2', function () { + return invoke(composer.retain('TripleAndIncrement', true), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) + }) + it('test 3', function () { + return invoke(composer.retain('TripleAndIncrement', ({ n }) => ({ n: -n })), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { params: { n: -3 }, result: { n: 10 } })) + }) + }) + + describe('repeat', function () { + it('test 1', function () { + return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) + }) + }) + describe('retry', function () { it('test 1', function () { - return invoke(composer.let('x', 2, composer.retry(2, () => x-- > 0 ? { error: 'foo' } : 42))) + return invoke(composer.let({ x: 2 }, composer.retry(2, () => x-- > 0 ? { error: 'foo' } : 42))) .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) }) it('test 2', function () { - return invoke(composer.let('x', 2, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42))) + return invoke(composer.let({ x: 2 }, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42))) .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result.error, 'foo')) }) }) From bf26ae19194f372ddc623b145844f2abab1f47d5 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 30 Jan 2018 14:04:01 -0500 Subject: [PATCH 16/62] Simplify finally implementation --- composer.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/composer.js b/composer.js index deeeb55..fe54c57 100644 --- a/composer.js +++ b/composer.js @@ -167,12 +167,7 @@ class Composer { if (arguments.length > 2) throw new Error('Too many arguments') const id = {} handler = this.task(handler) - body = chain(push(id, { catch: handler.entry }), chain(this.task(body), pop(id))) - body.states.push(...handler.states) - body.exit.next = handler.entry - body.exit = handler.exit - body.Manifest.push(...handler.Manifest) - return body + return chain(push(id, { catch: handler.entry }), chain(this.task(body), chain(pop(id), handler))) } let(obj) { // varargs From 03d18a8422d8e16bbe40498cdffcb371fd1f275b Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 30 Jan 2018 14:15:55 -0500 Subject: [PATCH 17/62] Option to not retain parameters in if and while --- composer.js | 26 +++++++++++++------------- test/test.js | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/composer.js b/composer.js index fe54c57..08c2cb0 100644 --- a/composer.js +++ b/composer.js @@ -110,12 +110,12 @@ class Composer { return Array.prototype.map.call(arguments, x => this.task(x), this).reduce(chain) } - if(test, consequent, alternate) { - if (arguments.length > 3) throw new Error('Too many arguments') + if(test, consequent, alternate, options) { + if (arguments.length > 4) throw new Error('Too many arguments') const id = {} - test = chain(push(id), this.task(test)) - consequent = chain(pop(id, 'pop', 'then'), this.task(consequent)) - alternate = chain(pop(id, 'pop', 'else'), this.task(alternate)) + test = options ? this.task(test) : chain(push(id), this.task(test)) + consequent = options ? this.task(consequent) : chain(pop(id, 'pop', 'then'), this.task(consequent)) + alternate = options ? this.task(alternate) : chain(pop(id, 'pop', 'else'), this.task(alternate)) const exit = { type: 'pass', id } const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } test.states.push(choice) @@ -131,19 +131,19 @@ class Composer { return test } - while(test, body) { - if (arguments.length > 2) throw new Error('Too many arguments') + while(test, body, options) { + if (arguments.length > 3) throw new Error('Too many arguments') const id = {} - test = chain(push(id), this.task(test)) - const consequent = chain(pop(id, 'pop', 'then'), this.task(body)) - const alternate = pop(id, 'pop', 'else') - const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } + test = options ? this.task(test) : chain(push(id), this.task(test)) + const consequent = options ? this.task(body) : chain(pop(id, 'pop', 'then'), this.task(body)) + const exit = options ? { type: 'pass', id } : pop(id, 'pop', 'else').entry + const choice = { type: 'choice', then: consequent.entry, else: exit, id } test.states.push(choice) test.states.push(...consequent.states) - test.states.push(...alternate.states) + test.states.push(exit) test.exit.next = choice consequent.exit.next = test.entry - test.exit = alternate.exit + test.exit = exit test.Manifest.push(...consequent.Manifest) return test } diff --git a/test/test.js b/test/test.js index 1c17dc5..0cb6286 100644 --- a/test/test.js +++ b/test/test.js @@ -130,18 +130,33 @@ describe('composer', function () { return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { n: 10 })) }) + + it('then branch no retain', function () { + return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, true), { n: 2 }) + .then(activation => assert.deepEqual(activation.response.result, { value: true, then: true })) + }) + + it('else branch no retain', function () { + return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, true), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { value: false, else: true })) + }) }) describe('while', function () { - it('test 1', function () { + it('a few iterations', function () { return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) }) - it('test 2', function () { + it('no iteration', function () { return invoke(composer.while(() => false, ({ n }) => ({ n: n - 1 })), { n: 1 }) .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) }) + + it('no retain', function () { + return invoke(composer.while(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 }), true), { n: 4 }) + .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) + }) }) describe('try', function () { From 6ea51380af77140c29a4a15970d7337f83bdd20e Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 30 Jan 2018 14:41:58 -0500 Subject: [PATCH 18/62] Use objects for all options --- composer.js | 81 +++++++++++++++++++++++++++++----------------------- test/test.js | 10 +++---- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/composer.js b/composer.js index 08c2cb0..c709510 100644 --- a/composer.js +++ b/composer.js @@ -28,8 +28,10 @@ const openwhisk = require('openwhisk') class ComposerError extends Error { constructor(message, cause) { super(message) - const index = this.stack.indexOf('\n') - this.stack = this.stack.substring(0, index) + '\nCause: ' + util.inspect(cause) + this.stack.substring(index) + if (typeof cause !== 'undefined') { + const index = this.stack.indexOf('\n') + this.stack = this.stack.substring(0, index) + '\nCause: ' + util.inspect(cause) + this.stack.substring(index) + } } } @@ -81,7 +83,7 @@ class Composer { } task(obj) { - if (arguments.length > 1) throw new Error('Too many arguments') + if (arguments.length > 1) throw new ComposerError('Too many arguments') if (obj == null) { // case null: identity function (must throw errors if any) return this.function(params => params, { helper: 'null' }) @@ -110,12 +112,13 @@ class Composer { return Array.prototype.map.call(arguments, x => this.task(x), this).reduce(chain) } - if(test, consequent, alternate, options) { - if (arguments.length > 4) throw new Error('Too many arguments') + if(test, consequent, alternate, options = {}) { + if (arguments.length > 4) throw new ComposerError('Too many arguments') + if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) const id = {} - test = options ? this.task(test) : chain(push(id), this.task(test)) - consequent = options ? this.task(consequent) : chain(pop(id, 'pop', 'then'), this.task(consequent)) - alternate = options ? this.task(alternate) : chain(pop(id, 'pop', 'else'), this.task(alternate)) + test = options.nosave ? this.task(test) : chain(push(id), this.task(test)) + consequent = options.nosave ? this.task(consequent) : chain(pop(id, 'pop', 'then'), this.task(consequent)) + alternate = options.nosave ? this.task(alternate) : chain(pop(id, 'pop', 'else'), this.task(alternate)) const exit = { type: 'pass', id } const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } test.states.push(choice) @@ -131,12 +134,13 @@ class Composer { return test } - while(test, body, options) { - if (arguments.length > 3) throw new Error('Too many arguments') + while(test, body, options = {}) { + if (arguments.length > 3) throw new ComposerError('Too many arguments') + if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) const id = {} - test = options ? this.task(test) : chain(push(id), this.task(test)) - const consequent = options ? this.task(body) : chain(pop(id, 'pop', 'then'), this.task(body)) - const exit = options ? { type: 'pass', id } : pop(id, 'pop', 'else').entry + test = options.nosave ? this.task(test) : chain(push(id), this.task(test)) + const consequent = options.nosave ? this.task(body) : chain(pop(id, 'pop', 'then'), this.task(body)) + const exit = options.nosave ? { type: 'pass', id } : pop(id, 'pop', 'else').entry const choice = { type: 'choice', then: consequent.entry, else: exit, id } test.states.push(choice) test.states.push(...consequent.states) @@ -149,7 +153,7 @@ class Composer { } try(body, handler) { - if (arguments.length > 2) throw new Error('Too many arguments') + if (arguments.length > 2) throw new ComposerError('Too many arguments') const id = {} handler = this.task(handler) body = chain(push(id, { catch: handler.entry }), chain(this.task(body), pop(id))) @@ -164,7 +168,7 @@ class Composer { } finally(body, handler) { - if (arguments.length > 2) throw new Error('Too many arguments') + if (arguments.length > 2) throw new ComposerError('Too many arguments') const id = {} handler = this.task(handler) return chain(push(id, { catch: handler.entry }), chain(this.task(body), chain(pop(id), handler))) @@ -177,48 +181,55 @@ class Composer { } value(v) { - if (arguments.length > 1) throw new Error('Too many arguments') + if (arguments.length > 1) throw new ComposerError('Too many arguments') if (typeof v === 'function') throw new ComposerError('Invalid argument', v) const entry = { type: 'value', value: typeof v === 'undefined' ? {} : JSON.parse(JSON.stringify(v)) } return { entry, states: [entry], exit: entry, Manifest: [] } } - function(f, options) { - if (arguments.length > 2) throw new Error('Too many arguments') + function(f, options = {}) { + if (arguments.length > 2) throw new ComposerError('Too many arguments') + if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) if (typeof f === 'function') f = `${f}` if (typeof f !== 'string') throw new ComposerError('Invalid argument', f) const entry = { type: 'function', function: f } - if (options && options.helper) entry.Helper = options.helper + if (options.helper) entry.helper = options.helper return { entry, states: [entry], exit: entry, Manifest: [] } } - action(action, options) { - if (arguments.length > 2) throw new Error('Too many arguments') + action(action, options = {}) { + if (arguments.length > 2) throw new ComposerError('Too many arguments') + if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) if (typeof action !== 'string') throw new ComposerError('Invalid argument', f) let Manifest = [] - if (options && options.filename) Manifest = [{ name: obj, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] - if (options && typeof options.action === 'string') Manifest = [{ name: obj, action: options.action }] - if (options && typeof options.action === 'function') Manifest = [{ name: obj, action: `${options.action}` }] + if (options.filename) Manifest = [{ name: obj, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] + if (typeof options.action === 'string') Manifest = [{ name: obj, action: options.action }] + if (typeof options.action === 'function') Manifest = [{ name: obj, action: `${options.action}` }] const entry = { type: 'action', action } return { entry, states: [entry], exit: entry, Manifest } } - retain(body, options) { - if (arguments.length > 2) throw new Error('Too many arguments') - if (typeof options === 'undefined' || typeof options === 'string' || options === false) { - const id = {} - return chain(push(id, options), chain(this.task(body), pop(id, 'collect'))) - } else if (options === true) { + retain(body, options = {}) { + if (arguments.length > 2) throw new ComposerError('Too many arguments') + if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) + if (typeof options.filter === 'function') { + // return { params: filter(params), result: body(params) } + return this.sequence(this.retain(options.filter), + this.retain(this.finally(this.function(({ params }) => params, { helper: 'retain_4' }), body), { field: 'result', catch: options.catch })) + } + if (options.catch) { + // return { params, result: body(params) } even if result is an error return this.sequence( this.retain( this.try( this.sequence(body, this.function(result => ({ result }), { helper: 'retain_1' })), - this.function(result => ({ result }), { helper: 'retain_3' }))), + this.function(result => ({ result }), { helper: 'retain_3' })), + options.field), this.function(({ params, result }) => ({ params, result: result.result }), { helper: 'retain_2' })) - } else if (typeof options === 'function') { - return this.sequence(this.retain(options), this.retain(this.finally(this.function(({ params }) => params, { helper: 'retain_4' }), body), 'result')) } else { - throw new ComposerError('Invalid argument', options) + // return { params, result: body(params) } if no error, otherwise body(params) + const id = {} + return chain(push(id, options.field), chain(this.task(body), pop(id, 'collect'))) } } @@ -229,7 +240,7 @@ class Composer { retry(count) { // varargs if (typeof count !== 'number') throw new ComposerError('Invalid argument', count) - const attempt = this.retain(this.sequence(...Array.prototype.slice.call(arguments, 1)), true) + const attempt = this.retain(this.sequence(...Array.prototype.slice.call(arguments, 1)), { catch: true }) return this.let({ count }, attempt, this.while( diff --git a/test/test.js b/test/test.js index 0cb6286..488da5e 100644 --- a/test/test.js +++ b/test/test.js @@ -132,12 +132,12 @@ describe('composer', function () { }) it('then branch no retain', function () { - return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, true), { n: 2 }) + return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, { nosave: true }), { n: 2 }) .then(activation => assert.deepEqual(activation.response.result, { value: true, then: true })) }) it('else branch no retain', function () { - return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, true), { n: 3 }) + return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, { nosave: true }), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { value: false, else: true })) }) }) @@ -154,7 +154,7 @@ describe('composer', function () { }) it('no retain', function () { - return invoke(composer.while(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 }), true), { n: 4 }) + return invoke(composer.while(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 }), { nosave: true }), { n: 4 }) .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) }) }) @@ -217,11 +217,11 @@ describe('composer', function () { }) it('test 2', function () { - return invoke(composer.retain('TripleAndIncrement', true), { n: 3 }) + return invoke(composer.retain('TripleAndIncrement', { catch: true }), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) }) it('test 3', function () { - return invoke(composer.retain('TripleAndIncrement', ({ n }) => ({ n: -n })), { n: 3 }) + return invoke(composer.retain('TripleAndIncrement', { filter: ({ n }) => ({ n: -n }) }), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { params: { n: -3 }, result: { n: 10 } })) }) }) From d8bafd1ebcd54cfe5f369fad3eaded51f1d80dba Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 30 Jan 2018 16:32:47 -0500 Subject: [PATCH 19/62] Many more tests --- composer.js | 2 +- test/test.js | 249 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 196 insertions(+), 55 deletions(-) diff --git a/composer.js b/composer.js index c709510..85d7271 100644 --- a/composer.js +++ b/composer.js @@ -224,7 +224,7 @@ class Composer { this.try( this.sequence(body, this.function(result => ({ result }), { helper: 'retain_1' })), this.function(result => ({ result }), { helper: 'retain_3' })), - options.field), + { field: options.field }), this.function(({ params, result }) => ({ params, result: result.result }), { helper: 'retain_2' })) } else { // return { params, result: body(params) } if no error, otherwise body(params) diff --git a/test/test.js b/test/test.js index 488da5e..db53430 100644 --- a/test/test.js +++ b/test/test.js @@ -1,13 +1,14 @@ const assert = require('assert') const composer = require('../composer') -const name = 'composer-test-action' +const name = 'TestAction' +// compile, deploy, and blocking invoke const invoke = (task, params = {}, blocking = true) => composer.deploy(composer.compile(name, task)).then(() => composer.wsk.actions.invoke({ name, params, blocking })) describe('composer', function () { this.timeout(20000) - before('deploy conductor and sample actions', function () { + before('deploy test actions', function () { return composer.deploy( [{ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' }, { name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' }, @@ -16,43 +17,104 @@ describe('composer', function () { }) describe('blocking invocations', function () { + describe('actions', function () { + it('action must return true', function () { + return invoke(composer.action('isNotOne'), { n: 0 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) + }) + + it('action must return false', function () { + return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) + }) + + it('well-formedness', function () { + try { + invoke(composer.function('foo', 'bar')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) + }) + + describe('values', function () { + it('true', function () { + return invoke(composer.value(true)).then(activation => assert.deepEqual(activation.response.result, { value: true })) + }) + + it('42', function () { + return invoke(composer.value(42)).then(activation => assert.deepEqual(activation.response.result, { value: 42 })) + }) + + it('well-formedness', function () { + try { + invoke(composer.value('foo', 'bar')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) + }) + + describe('functions', function () { + it('function must return true', function () { + return invoke(composer.function(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) + }) + + it('function must return false', function () { + return invoke(composer.function(function ({ n }) { return n % 2 === 0 }), { n: 3 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) + }) + + it('function must fail', function () { + return invoke(composer.function(() => n)).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error.startsWith('An exception was caught'))) + }) + + it('function must throw', function () { + return invoke(composer.function(() => ({ error: 'foo', n: 42 }))).then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) + }) + + it('function must mutate params', function () { + return invoke(composer.function(params => { params.foo = 'foo' }), { bar: 42 }).then(activation => assert.deepEqual(activation.response.result, { foo: 'foo', bar: 42 })) + }) + + it('function as string', function () { + return invoke(composer.function('({ n }) => n % 2 === 0'), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) + }) + + it('well-formedness', function () { + try { + invoke(composer.function(() => n, 'foo')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) + }) + describe('tasks', function () { - describe('actions', function () { + describe('action tasks', function () { it('action must return true', function () { return invoke(composer.task('isNotOne'), { n: 0 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) }) - - it('action must return false', function () { - return invoke(composer.task('isNotOne'), { n: 1 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) - }) }) - describe('functions', function () { + describe('function tasks', function () { it('function must return true', function () { return invoke(composer.task(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) }) - - it('function must return false', function () { - return invoke(composer.task(function ({ n }) { return n % 2 === 0 }), { n: 3 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) - }) - - it('function must fail', function () { - return invoke(composer.task(() => n)).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error.startsWith('An exception was caught at state'))) - }) }) - describe('values', function () { - it('true', function () { - return invoke(composer.value(true)).then(activation => assert.deepEqual(activation.response.result, { value: true })) + describe('null task', function () { + it('null task must return input', function () { + return invoke(composer.task(), { foo: 'bar' }).then(activation => assert.deepEqual(activation.response.result, { foo: 'bar' })) }) - it('42', function () { - return invoke(composer.value(42)).then(activation => assert.deepEqual(activation.response.result, { value: 42 })) + it('null task must fail on error input', function () { + return invoke(composer.task(), { error: 'bar' }).then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'bar' })) }) }) - describe('invalid', function () { - it('false must throw', function () { + describe('invalid tasks', function () { + it('a Boolean is not a valid task', function () { try { invoke(composer.task(false)) assert.fail() @@ -61,7 +123,7 @@ describe('composer', function () { } }) - it('42 must throw', function () { + it('a number is not a valid task', function () { try { invoke(composer.task(42)) assert.fail() @@ -70,7 +132,7 @@ describe('composer', function () { } }) - it('{ foo: \'bar\' } must throw', function () { + it('a dictionary is not a valid task', function () { try { invoke(composer.task({ foo: 'bar' })) assert.fail() @@ -80,10 +142,13 @@ describe('composer', function () { }) }) - describe('pass', function () { - it('pass must return input object', function () { - return invoke(composer.task(), { foo: 'bar' }).then(activation => assert.deepEqual(activation.response.result, { foo: 'bar' })) - }) + it('well-formedness', function () { + try { + invoke(composer.task('foo', 'bar')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } }) }) @@ -111,35 +176,44 @@ describe('composer', function () { }) describe('if', function () { - it('then branch no else branch', function () { - return invoke(composer.if('isEven', 'DivideByTwo'), { n: 4 }) + it('condition = true', function () { + return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 4 }) .then(activation => assert.deepEqual(activation.response.result, { n: 2 })) }) - it('no else branch', function () { - return invoke(composer.if('isEven', 'DivideByTwo'), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 3 })) + it('condition = false', function () { + return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 10 })) }) - it('then branch', function () { - return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 4 }) + it('condition = true, then branch only', function () { + return invoke(composer.if('isEven', 'DivideByTwo'), { n: 4 }) .then(activation => assert.deepEqual(activation.response.result, { n: 2 })) }) - it('else branch', function () { - return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 10 })) + it('condition = false, then branch only', function () { + return invoke(composer.if('isEven', 'DivideByTwo'), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 3 })) }) - it('then branch no retain', function () { + it('condition = true, nosave option', function () { return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, { nosave: true }), { n: 2 }) .then(activation => assert.deepEqual(activation.response.result, { value: true, then: true })) }) - it('else branch no retain', function () { + it('condition = false, nosave option', function () { return invoke(composer.if('isEven', params => { params.then = true }, params => { params.else = true }, { nosave: true }), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { value: false, else: true })) }) + + it('well-formedness', function () { + try { + invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', 'TripleAndIncrement')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) }) describe('while', function () { @@ -153,34 +227,61 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) }) - it('no retain', function () { + it('nosave option', function () { return invoke(composer.while(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 }), { nosave: true }), { n: 4 }) .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) }) + + it('well-formedness', function () { + try { + invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) }) describe('try', function () { - it('test 1', function () { + it('no error', function () { return invoke(composer.try(() => true, error => ({ message: error.error }))) .then(activation => assert.deepEqual(activation.response.result, { value: true })) }) - it('test 2', function () { + it('error', function () { return invoke(composer.try(() => ({ error: 'foo' }), error => ({ message: error.error }))) .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) + + it('well-formedness', function () { + try { + invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('finally', function () { - it('test 1', function () { + it('no error', function () { return invoke(composer.finally(() => true, params => ({ params }))) .then(activation => assert.deepEqual(activation.response.result, { params: { value: true } })) }) - it('test 2', function () { + it('error', function () { return invoke(composer.finally(() => ({ error: 'foo' }), params => ({ params }))) .then(activation => assert.deepEqual(activation.response.result, { params: { error: 'foo' } })) }) + + it('well-formedness', function () { + try { + invoke(composer.finally('isNotOne', 'isNotOne', 'isNotOne')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('let', function () { @@ -211,35 +312,75 @@ describe('composer', function () { }) describe('retain', function () { - it('test 1', function () { + it('base case', function () { return invoke(composer.retain('TripleAndIncrement'), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) }) - it('test 2', function () { - return invoke(composer.retain('TripleAndIncrement', { catch: true }), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) + it('throw error', function () { + return invoke(composer.retain(() => ({ error: 'foo' })), { n: 3 }) + .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) + }) + + it('catch error', function () { + return invoke(composer.retain(() => ({ error: 'foo' }), { catch: true }), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { error: 'foo' } })) + }) + + it('select field', function () { + return invoke(composer.retain('TripleAndIncrement', { field: 'p' }), { n: 3, p: 4 }) + .then(activation => assert.deepEqual(activation.response.result, { params: 4, result: { n: 10 } })) + }) + + it('select field, throw error', function () { + return invoke(composer.retain(() => ({ error: 'foo' }), { field: 'p' }), { n: 3, p: 4 }) + .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) }) - it('test 3', function () { + + it('select field, catch error', function () { + return invoke(composer.retain(() => ({ error: 'foo' }), { field: 'p', catch: true }), { n: 3, p: 4 }) + .then(activation => assert.deepEqual(activation.response.result, { params: 4, result: { error: 'foo' } })) + }) + + it('filter function', function () { return invoke(composer.retain('TripleAndIncrement', { filter: ({ n }) => ({ n: -n }) }), { n: 3 }) .then(activation => assert.deepEqual(activation.response.result, { params: { n: -3 }, result: { n: 10 } })) }) + + it('filter function, throw error', function () { + return invoke(composer.retain(() => ({ error: 'foo' }), { filter: ({ n }) => ({ n: -n }) }), { n: 3 }) + .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) + }) + + it('filter function, catch error', function () { + return invoke(composer.retain(() => ({ error: 'foo' }), { filter: ({ n }) => ({ n: -n }), catch: true }), { n: 3 }) + .then(activation => assert.deepEqual(activation.response.result, { params: { n: - 3 }, result: { error: 'foo' } })) + }) + + it('well-formedness', function () { + try { + invoke(composer.retain('isNotOne', 'isNotOne')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) }) describe('repeat', function () { - it('test 1', function () { + it('a few iterations', function () { return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 }) .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) }) }) describe('retry', function () { - it('test 1', function () { + it('success', function () { return invoke(composer.let({ x: 2 }, composer.retry(2, () => x-- > 0 ? { error: 'foo' } : 42))) .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) }) - it('test 2', function () { + it('failure', function () { return invoke(composer.let({ x: 2 }, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42))) .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result.error, 'foo')) }) From b3044b3b546bcfbe8201796ffe127ec5bc1de834 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 30 Jan 2018 18:00:16 -0500 Subject: [PATCH 20/62] Bug fix and more tests --- composer.js | 12 ++++---- test/test.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/composer.js b/composer.js index 85d7271..49c4b80 100644 --- a/composer.js +++ b/composer.js @@ -197,15 +197,15 @@ class Composer { return { entry, states: [entry], exit: entry, Manifest: [] } } - action(action, options = {}) { + action(name, options = {}) { if (arguments.length > 2) throw new ComposerError('Too many arguments') if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) - if (typeof action !== 'string') throw new ComposerError('Invalid argument', f) + if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) let Manifest = [] - if (options.filename) Manifest = [{ name: obj, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] - if (typeof options.action === 'string') Manifest = [{ name: obj, action: options.action }] - if (typeof options.action === 'function') Manifest = [{ name: obj, action: `${options.action}` }] - const entry = { type: 'action', action } + if (options.filename) Manifest = [{ name, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] + if (typeof options.action === 'string') Manifest = [{ name, action: options.action }] + if (typeof options.action === 'function') Manifest = [{ name, action: `${options.action}` }] + const entry = { type: 'action', action: name } return { entry, states: [entry], exit: entry, Manifest } } diff --git a/test/test.js b/test/test.js index db53430..e537e9f 100644 --- a/test/test.js +++ b/test/test.js @@ -26,7 +26,7 @@ describe('composer', function () { return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) }) - it('well-formedness', function () { + it('invalid options', function () { try { invoke(composer.function('foo', 'bar')) assert.fail() @@ -34,6 +34,24 @@ describe('composer', function () { assert.equal(error, 'Error: Invalid argument') } }) + + it('invalid name argument', function () { + try { + invoke(composer.function(42)) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) + + it('too many arguments', function () { + try { + invoke(composer.function('foo', {}, 'bar')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('values', function () { @@ -45,7 +63,7 @@ describe('composer', function () { return invoke(composer.value(42)).then(activation => assert.deepEqual(activation.response.result, { value: 42 })) }) - it('well-formedness', function () { + it('too many arguments', function () { try { invoke(composer.value('foo', 'bar')) assert.fail() @@ -80,7 +98,7 @@ describe('composer', function () { return invoke(composer.function('({ n }) => n % 2 === 0'), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) }) - it('well-formedness', function () { + it('invalid options argument', function () { try { invoke(composer.function(() => n, 'foo')) assert.fail() @@ -88,6 +106,24 @@ describe('composer', function () { assert.equal(error, 'Error: Invalid argument') } }) + + it('invalid function argument', function () { + try { + invoke(composer.function(42)) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Invalid argument') + } + }) + + it('too many arguments', function () { + try { + invoke(composer.function(() => n, {}, () => { })) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('tasks', function () { @@ -142,7 +178,7 @@ describe('composer', function () { }) }) - it('well-formedness', function () { + it('too many arguments', function () { try { invoke(composer.task('foo', 'bar')) assert.fail() @@ -206,7 +242,7 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { value: false, else: true })) }) - it('well-formedness', function () { + it('invalid options argument', function () { try { invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', 'TripleAndIncrement')) assert.fail() @@ -214,6 +250,15 @@ describe('composer', function () { assert.equal(error, 'Error: Invalid argument') } }) + + it('too many arguments', function () { + try { + invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', {}, 'TripleAndIncrement')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('while', function () { @@ -232,7 +277,7 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) }) - it('well-formedness', function () { + it('invalid options argument', function () { try { invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) assert.fail() @@ -240,6 +285,15 @@ describe('composer', function () { assert.equal(error, 'Error: Invalid argument') } }) + + it('too many arguments', function () { + try { + invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), {}, ({ n }) => ({ n: n - 1 })), { n: 4 }) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('try', function () { @@ -253,7 +307,7 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) - it('well-formedness', function () { + it('too many arguments', function () { try { invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) assert.fail() @@ -274,7 +328,7 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { params: { error: 'foo' } })) }) - it('well-formedness', function () { + it('too many arguments', function () { try { invoke(composer.finally('isNotOne', 'isNotOne', 'isNotOne')) assert.fail() @@ -357,7 +411,7 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { params: { n: - 3 }, result: { error: 'foo' } })) }) - it('well-formedness', function () { + it('invalid options', function () { try { invoke(composer.retain('isNotOne', 'isNotOne')) assert.fail() @@ -365,6 +419,15 @@ describe('composer', function () { assert.equal(error, 'Error: Invalid argument') } }) + + it('too many arguments', function () { + try { + invoke(composer.retain('isNotOne', {}, 'isNotOne')) + assert.fail() + } catch (error) { + assert.equal(error, 'Error: Too many arguments') + } + }) }) describe('repeat', function () { From f3c0a9b16976511eb90b6e3d71240290952928f4 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 31 Jan 2018 10:29:35 -0500 Subject: [PATCH 21/62] Update README.md --- README.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c183848..7febfc9 100644 --- a/README.md +++ b/README.md @@ -5,25 +5,27 @@ Composer is a new programming model from [IBM Research](https://ibm.biz/serverless-research) for composing [IBM Cloud Functions](https://ibm.biz/openwhisk), built on [Apache -OpenWhisk](https://github.com/apache/incubator-openwhisk). Composer -extends Functions and sequences with more powerful control flow and -automatic state management. With it, developers can build even more +OpenWhisk](https://github.com/apache/incubator-openwhisk). +With composer, developers can build even more serverless applications including using it for IoT, with workflow orchestration, conversation services, and devops automation, to name a few examples. -Composer helps you express cloud-native apps that are serverless by -construction: scale automatically, and pay as you go and not for idle -time. Programming compositions for IBM Cloud Functions is done via the -[functions shell](https://github.com/ibm-functions/shell), which +Composer extends Functions and sequences with more powerful control +flow and automatic state management. +Composer helps express cloud-native apps that are serverless by +construction: scale automatically, pay as you go and not for idle time. + + +The [IBM Cloud functions shell](https://github.com/ibm-functions/shell) offers a CLI and graphical interface for fast, incremental, iterative, and local development of serverless apps. Some additional highlights of the shell include: -* Edit your code and program using your favorite text editor, rather than using a drag-n-drop UI -* Validate your compositions with readily accessible visualizations, without switching tools or using a browser -* Deploy and invoke compositions using familiar CLI commands -* Debug your invocations with either familiar CLI commands or readily accessible visualizations +* Edit your code and program using your favorite text editor, rather than using a drag-n-drop UI. +* Validate compositions with readily accessible visualizations, without switching tools or using a browser. +* Deploy and invoke compositions using familiar CLI commands. +* Debug invocations with either familiar CLI commands or readily accessible visualizations. Composer and shell are currently available as IBM Research previews. We are excited about both and are looking forward to what @@ -39,11 +41,7 @@ you to [join us on slack](http://ibm.biz/composer-users). This repository includes: - * [tutorial](docs) for getting started with Composer in the [docs](docs) folder, - * [composer](composer.js) node.js module to author compositions using JavaScript, - * [conductor](conductor.js) action code to orchestrate the execution of compositions, - * [manager](manager.js) node.js module to query the state of compositions, - * [test-harness](test-harness.js) helper module for testing composer, - * [redis-promise](redis-promise.js) helper module that implements a promisified redis client for node.js, + * a [composer](composer.js) node.js module to author compositions using JavaScript, + * a [tutorial](docs) for getting started with composer in the [docs](docs) folder, * example compositions in the [samples](samples) folder, - * unit tests in the [test](test) folder. + * tests in the [test](test) folder. From e6bd1dcbecb71e0db2149cb064f33f86588314e0 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 31 Jan 2018 10:47:17 -0500 Subject: [PATCH 22/62] Forbid native function capture --- composer.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/composer.js b/composer.js index 49c4b80..2182853 100644 --- a/composer.js +++ b/composer.js @@ -190,7 +190,11 @@ class Composer { function(f, options = {}) { if (arguments.length > 2) throw new ComposerError('Too many arguments') if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) - if (typeof f === 'function') f = `${f}` + if (typeof f === 'function') { + const code = `${f}` + if (code === 'function () { [native code] }') throw new ComposerError('Cannot capture native function', f) + f = code + } if (typeof f !== 'string') throw new ComposerError('Invalid argument', f) const entry = { type: 'function', function: f } if (options.helper) entry.helper = options.helper @@ -204,7 +208,11 @@ class Composer { let Manifest = [] if (options.filename) Manifest = [{ name, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] if (typeof options.action === 'string') Manifest = [{ name, action: options.action }] - if (typeof options.action === 'function') Manifest = [{ name, action: `${options.action}` }] + if (typeof options.action === 'function') { + const action = `${options.action}` + if (action.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) + Manifest = [{ name, action }] + } const entry = { type: 'action', action: name } return { entry, states: [entry], exit: entry, Manifest } } From caf984ffbdbffa9969f0d27823af83d05dd8f654 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 31 Jan 2018 11:42:25 -0500 Subject: [PATCH 23/62] Enable defining and deploying native sequences --- composer.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/composer.js b/composer.js index 2182853..f1e6704 100644 --- a/composer.js +++ b/composer.js @@ -206,9 +206,14 @@ class Composer { if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) let Manifest = [] - if (options.filename) Manifest = [{ name, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] - if (typeof options.action === 'string') Manifest = [{ name, action: options.action }] - if (typeof options.action === 'function') { + if (options.filename) { // read action code from file + Manifest = [{ name, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] + } else if (options.sequence) { // native sequence + const components = options.sequence.map(a => a.indexOf('/') == -1 ? `/_/${a}` : a) + Manifest = [{ name, action: { exec: { kind: 'sequence', components } } }] + } else if (typeof options.action === 'string' || typeof options.action === 'object' && options.action !== null) { + Manifest = [{ name, action: options.action }] + } else if (typeof options.action === 'function') { const action = `${options.action}` if (action.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) Manifest = [{ name, action }] From 5e2c947e3c40bf3e64d6a6270a0ee66e58b36564 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Wed, 31 Jan 2018 20:24:59 -0500 Subject: [PATCH 24/62] Pass options to composer module --- composer.js | 6 ++++-- test/test.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/composer.js b/composer.js index f1e6704..eabf9f1 100644 --- a/composer.js +++ b/composer.js @@ -192,7 +192,7 @@ class Composer { if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) if (typeof f === 'function') { const code = `${f}` - if (code === 'function () { [native code] }') throw new ComposerError('Cannot capture native function', f) + if (code.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', f) f = code } if (typeof f !== 'string') throw new ComposerError('Invalid argument', f) @@ -264,6 +264,7 @@ class Composer { // produce action code compile(name, obj, filename) { + if (arguments.length > 3) throw new ComposerError('Too many arguments') if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid argument', filename) obj = this.task(obj) @@ -296,6 +297,7 @@ class Composer { // deploy actions, return count of successfully deployed actions deploy(actions) { + if (arguments.length > 1) throw new ComposerError('Too many arguments') if (!Array.isArray(actions)) throw new ComposerError('Invalid argument', array) // clone array as openwhisk mutates the actions return clone(actions).reduce( @@ -303,7 +305,7 @@ class Composer { } } -module.exports = new Composer() +module.exports = options => new Composer(options) // conductor action diff --git a/test/test.js b/test/test.js index e537e9f..7cc2240 100644 --- a/test/test.js +++ b/test/test.js @@ -1,5 +1,5 @@ const assert = require('assert') -const composer = require('../composer') +const composer = require('../composer')() const name = 'TestAction' // compile, deploy, and blocking invoke From ccac792cb4008401d04a7ad5d59a958e7b94f217 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 1 Feb 2018 11:03:15 -0500 Subject: [PATCH 25/62] Restore distinction between push/pop and enter/exit state pairs --- composer.js | 91 ++++++++++++++++++++++------------------------------- 1 file changed, 38 insertions(+), 53 deletions(-) diff --git a/composer.js b/composer.js index eabf9f1..00fe14a 100644 --- a/composer.js +++ b/composer.js @@ -35,8 +35,15 @@ class ComposerError extends Error { } } -// build a sequence of front and back, mutating front +// build a composition with a single state +function singleton(entry, Manifest = []) { + return { entry, states: [entry], exit: entry, Manifest } +} + +// chain two compositions, mutating front function chain(front, back) { + if (front.type) front = singleton(front) + if (back.type) back = singleton(back) front.states.push(...back.states) front.exit.next = back.entry front.exit = back.exit @@ -44,18 +51,6 @@ function chain(front, back) { return front } -// composition with one push state -function push(id, op) { - const entry = { type: 'push', id, op } - return { entry, states: [entry], exit: entry, Manifest: [] } -} - -// composition with one pop state -function pop(id, op, branch) { - const entry = { type: 'pop', id, op, branch } - return { entry, states: [entry], exit: entry, Manifest: [] } -} - class Composer { constructor(options = {}) { // try to extract apihost and key from wskprops @@ -89,9 +84,7 @@ class Composer { return this.function(params => params, { helper: 'null' }) } else if (Array.isArray(obj) && obj.length > 0 && typeof obj.slice(-1)[0].name === 'string') { // case array: last action in the array - const Manifest = clone(obj) - const entry = { type: 'action', action: obj.slice(-1)[0].name } - return { entry, states: [entry], exit: entry, Manifest } + return singleton({ type: 'action', action: obj.slice(-1)[0].name }, clone(obj)) } else if (typeof obj === 'object' && typeof obj.entry === 'object' && Array.isArray(obj.states) && typeof obj.exit === 'object' && Array.isArray(obj.Manifest)) { // case object: composition return clone(obj) @@ -116,9 +109,9 @@ class Composer { if (arguments.length > 4) throw new ComposerError('Too many arguments') if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) const id = {} - test = options.nosave ? this.task(test) : chain(push(id), this.task(test)) - consequent = options.nosave ? this.task(consequent) : chain(pop(id, 'pop', 'then'), this.task(consequent)) - alternate = options.nosave ? this.task(alternate) : chain(pop(id, 'pop', 'else'), this.task(alternate)) + test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) + consequent = options.nosave ? this.task(consequent) : chain({ type: 'pop', id, branch: 'then' }, this.task(consequent)) + alternate = options.nosave ? this.task(alternate) : chain({ type: 'pop', id, branch: 'else' }, this.task(alternate)) const exit = { type: 'pass', id } const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } test.states.push(choice) @@ -138,9 +131,9 @@ class Composer { if (arguments.length > 3) throw new ComposerError('Too many arguments') if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) const id = {} - test = options.nosave ? this.task(test) : chain(push(id), this.task(test)) - const consequent = options.nosave ? this.task(body) : chain(pop(id, 'pop', 'then'), this.task(body)) - const exit = options.nosave ? { type: 'pass', id } : pop(id, 'pop', 'else').entry + test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) + const consequent = options.nosave ? this.task(body) : chain({ type: 'pop', id, branch: 'then' }, this.task(body)) + const exit = options.nosave ? { type: 'pass', id } : { type: 'pop', id, branch: 'else' } const choice = { type: 'choice', then: consequent.entry, else: exit, id } test.states.push(choice) test.states.push(...consequent.states) @@ -156,7 +149,7 @@ class Composer { if (arguments.length > 2) throw new ComposerError('Too many arguments') const id = {} handler = this.task(handler) - body = chain(push(id, { catch: handler.entry }), chain(this.task(body), pop(id))) + body = [{ type: 'enter', id, frame: { catch: handler.entry } }, this.task(body), { type: 'exit', id }].reduce(chain) const exit = { type: 'pass', id } body.states.push(...handler.states) body.states.push(exit) @@ -171,20 +164,20 @@ class Composer { if (arguments.length > 2) throw new ComposerError('Too many arguments') const id = {} handler = this.task(handler) - return chain(push(id, { catch: handler.entry }), chain(this.task(body), chain(pop(id), handler))) + return [{ type: 'enter', id, frame: { catch: handler.entry } }, this.task(body), { type: 'exit', id }, handler].reduce(chain) } let(obj) { // varargs if (typeof obj !== 'object' || obj === null) throw new ComposerError('Invalid argument', obj) const id = {} - return chain(push(id, { let: JSON.parse(JSON.stringify(obj)) }), chain(this.sequence(...Array.prototype.slice.call(arguments, 1)), pop(id))) + return [{ type: 'enter', id, frame: { let: JSON.parse(JSON.stringify(obj)) } }, this.sequence(...Array.prototype.slice.call(arguments, 1)), { type: 'exit', id }].reduce(chain) } value(v) { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (typeof v === 'function') throw new ComposerError('Invalid argument', v) - const entry = { type: 'value', value: typeof v === 'undefined' ? {} : JSON.parse(JSON.stringify(v)) } - return { entry, states: [entry], exit: entry, Manifest: [] } + return singleton({ type: 'value', value: typeof v === 'undefined' ? {} : JSON.parse(JSON.stringify(v)) }) + } function(f, options = {}) { @@ -196,9 +189,8 @@ class Composer { f = code } if (typeof f !== 'string') throw new ComposerError('Invalid argument', f) - const entry = { type: 'function', function: f } - if (options.helper) entry.helper = options.helper - return { entry, states: [entry], exit: entry, Manifest: [] } + return singleton({ type: 'function', function: f, helper: options.helper }) + } action(name, options = {}) { @@ -218,8 +210,7 @@ class Composer { if (action.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) Manifest = [{ name, action }] } - const entry = { type: 'action', action: name } - return { entry, states: [entry], exit: entry, Manifest } + return singleton( { type: 'action', action: name }) } retain(body, options = {}) { @@ -242,7 +233,7 @@ class Composer { } else { // return { params, result: body(params) } if no error, otherwise body(params) const id = {} - return chain(push(id, options.field), chain(this.task(body), pop(id, 'collect'))) + return [{ type: 'push', id, field: options.field }, this.task(body), { type: 'pop', id, collect: true }].reduce(chain) } } @@ -285,7 +276,7 @@ class Composer { if (state.next) state.next = state.next.id if (state.then) state.then = state.then.id if (state.else) state.else = state.else.id - if (state.op && state.op.catch) state.op.catch = state.op.catch.id + if (state.frame && state.frame.catch) state.frame.catch = state.frame.catch.id }) obj.states.forEach(state => delete state.id) const composition = { entry, states, exit } @@ -404,28 +395,22 @@ function main(params) { if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) state = params.value === true ? json.then : json.else break + case 'enter': + if (!isObject(json.frame)) return badRequest(`State ${current} specified an invalid frame`) + stack.unshift(json.frame) + break + case 'exit': + if (stack.length === 0) return badRequest(`State ${current} attempted to pop from an empty stack`) + stack.shift() + break case 'push': - if (typeof json.op === 'string') { // push { params: params[op] } - stack.unshift(JSON.parse(JSON.stringify({ params: params[json.op] }))) - } else if (typeof json.op !== 'undefined') { // push op - stack.unshift(JSON.parse(JSON.stringify(json.op))) - } else { // push { params } - stack.unshift(JSON.parse(JSON.stringify({ params }))) - } + if (typeof json.field !== 'undefined' && typeof json.field !== 'string') return badRequest(`State ${current} is invalid`) + stack.unshift(JSON.parse(JSON.stringify({ params: json.field ? params[json.field] : params }))) break case 'pop': if (stack.length === 0) return badRequest(`State ${current} attempted to pop from an empty stack`) - const top = stack.shift() - switch (json.op) { - case 'pop': - params = top.params - break - case 'collect': - params = { params: top.params, result: params } - break - default: - // drop - } + if (typeof json.collect !== 'undefined' && typeof json.collect !== 'boolean') return badRequest(`State ${current} is invalid`) + params = json.collect ? { params: stack.shift().params, result: params } : stack.shift().params break case 'action': if (typeof json.action !== 'string') return badRequest(`State ${current} specifies an invalid action`) @@ -433,7 +418,7 @@ function main(params) { break case 'value': if (typeof json.value === 'undefined') return badRequest(`State ${current} specifies an invalid value`) - params = JSON.parse(JSON.stringify(json.value)) + params = json.value inspect() break case 'function': From 987ab50ef4ae0fa9bbe8e38eac76254d95c73f30 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 1 Feb 2018 11:38:42 -0500 Subject: [PATCH 26/62] Delete state labels after use --- composer.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/composer.js b/composer.js index 00fe14a..b2e6d71 100644 --- a/composer.js +++ b/composer.js @@ -110,8 +110,8 @@ class Composer { if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) const id = {} test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) - consequent = options.nosave ? this.task(consequent) : chain({ type: 'pop', id, branch: 'then' }, this.task(consequent)) - alternate = options.nosave ? this.task(alternate) : chain({ type: 'pop', id, branch: 'else' }, this.task(alternate)) + consequent = options.nosave ? this.task(consequent) : chain({ type: 'pop', id, label: 'then' }, this.task(consequent)) + alternate = options.nosave ? this.task(alternate) : chain({ type: 'pop', id, label: 'else' }, this.task(alternate)) const exit = { type: 'pass', id } const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } test.states.push(choice) @@ -132,8 +132,8 @@ class Composer { if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) const id = {} test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) - const consequent = options.nosave ? this.task(body) : chain({ type: 'pop', id, branch: 'then' }, this.task(body)) - const exit = options.nosave ? { type: 'pass', id } : { type: 'pop', id, branch: 'else' } + const consequent = options.nosave ? this.task(body) : chain({ type: 'pop', id, label: 'then' }, this.task(body)) + const exit = options.nosave ? { type: 'pass', id } : { type: 'pop', id, label: 'else' } const choice = { type: 'choice', then: consequent.entry, else: exit, id } test.states.push(choice) test.states.push(...consequent.states) @@ -266,7 +266,8 @@ class Composer { obj.states.forEach(state => { if (typeof state.id === 'undefined') state.id = {} if (typeof state.id.id === 'undefined') state.id.id = count++ - const id = state.type + '_' + (state.branch ? state.branch + '_' : '') + state.id.id + const id = state.type + '_' + (state.label ? state.label + '_' : '') + state.id.id + delete state.label states[id] = state state.id = id if (state === obj.entry) entry = id From 947375103980f7e19f0c2bf0e37f15a9484cd09b Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 1 Feb 2018 12:09:12 -0500 Subject: [PATCH 27/62] Fix error handling bug in while composition --- composer.js | 14 ++++++-------- test/test.js | 5 +++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/composer.js b/composer.js index b2e6d71..a138651 100644 --- a/composer.js +++ b/composer.js @@ -133,15 +133,16 @@ class Composer { const id = {} test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) const consequent = options.nosave ? this.task(body) : chain({ type: 'pop', id, label: 'then' }, this.task(body)) - const exit = options.nosave ? { type: 'pass', id } : { type: 'pop', id, label: 'else' } - const choice = { type: 'choice', then: consequent.entry, else: exit, id } + const alternate = options.nosave ? this.task() : chain({ type: 'pop', id, label: 'else' }, this.task()) + const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } test.states.push(choice) test.states.push(...consequent.states) - test.states.push(exit) + test.states.push(...alternate.states) test.exit.next = choice consequent.exit.next = test.entry - test.exit = exit + test.exit = alternate.exit test.Manifest.push(...consequent.Manifest) + test.states.push(...alternate.Manifest) return test } @@ -149,13 +150,10 @@ class Composer { if (arguments.length > 2) throw new ComposerError('Too many arguments') const id = {} handler = this.task(handler) - body = [{ type: 'enter', id, frame: { catch: handler.entry } }, this.task(body), { type: 'exit', id }].reduce(chain) const exit = { type: 'pass', id } + body = [{ type: 'enter', id, frame: { catch: handler.entry } }, this.task(body), { type: 'exit', id }, exit].reduce(chain) body.states.push(...handler.states) - body.states.push(exit) - body.exit.next = exit handler.exit.next = exit - body.exit = exit body.Manifest.push(...handler.Manifest) return body } diff --git a/test/test.js b/test/test.js index 7cc2240..fab86c7 100644 --- a/test/test.js +++ b/test/test.js @@ -307,6 +307,11 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) + it('while must throw even without iterations', function () { + return invoke(composer.try(composer.while(composer.value(false)), error => ({ message: error.error })), { error: 'foo' }) + .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) + }) + it('too many arguments', function () { try { invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) From 160d135d91166f8088784326e8a9eeb0ea007289 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 1 Feb 2018 13:11:27 -0500 Subject: [PATCH 28/62] Collapse consecutive pass states. Encode empty task with pass state. --- composer.js | 30 +++++++++++++++++++++--------- test/test.js | 12 +++++++++++- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/composer.js b/composer.js index a138651..1fea051 100644 --- a/composer.js +++ b/composer.js @@ -81,7 +81,7 @@ class Composer { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (obj == null) { // case null: identity function (must throw errors if any) - return this.function(params => params, { helper: 'null' }) + return singleton({ type: 'pass', id: {} }) } else if (Array.isArray(obj) && obj.length > 0 && typeof obj.slice(-1)[0].name === 'string') { // case array: last action in the array return singleton({ type: 'action', action: obj.slice(-1)[0].name }, clone(obj)) @@ -151,7 +151,7 @@ class Composer { const id = {} handler = this.task(handler) const exit = { type: 'pass', id } - body = [{ type: 'enter', id, frame: { catch: handler.entry } }, this.task(body), { type: 'exit', id }, exit].reduce(chain) + body = [{ type: 'try', id, catch: handler.entry }, this.task(body), { type: 'exit', id }, exit].reduce(chain) body.states.push(...handler.states) handler.exit.next = exit body.Manifest.push(...handler.Manifest) @@ -162,13 +162,13 @@ class Composer { if (arguments.length > 2) throw new ComposerError('Too many arguments') const id = {} handler = this.task(handler) - return [{ type: 'enter', id, frame: { catch: handler.entry } }, this.task(body), { type: 'exit', id }, handler].reduce(chain) + return [{ type: 'try', id, catch: handler.entry }, this.task(body), { type: 'exit', id }, handler].reduce(chain) } let(obj) { // varargs if (typeof obj !== 'object' || obj === null) throw new ComposerError('Invalid argument', obj) const id = {} - return [{ type: 'enter', id, frame: { let: JSON.parse(JSON.stringify(obj)) } }, this.sequence(...Array.prototype.slice.call(arguments, 1)), { type: 'exit', id }].reduce(chain) + return [{ type: 'let', id, let: JSON.parse(JSON.stringify(obj)) }, this.sequence(...Array.prototype.slice.call(arguments, 1)), { type: 'exit', id }].reduce(chain) } value(v) { @@ -208,7 +208,7 @@ class Composer { if (action.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) Manifest = [{ name, action }] } - return singleton( { type: 'action', action: name }) + return singleton({ type: 'action', action: name }) } retain(body, options = {}) { @@ -270,12 +270,21 @@ class Composer { state.id = id if (state === obj.entry) entry = id if (state === obj.exit) exit = id + while (state.next && state.next.type === 'pass' && state.next.next && state.next.next.type === 'pass') state.next = state.next.next + if (state.type === 'choice') { + while (state.then && state.then.type === 'pass' && state.then.next && state.then.next.type === 'pass') state.then = state.then.next + while (state.else && state.else.type === 'pass' && state.else.next && state.else.next.type === 'pass') state.else = state.else.next + } + if (state.type === 'try') { + while (state.catch && state.catch.type === 'pass' && state.catch.next && state.catch.next.type === 'pass') state.catch = state.catch.next + } }) + obj.states.forEach(state => { if (state.type === 'pass' && state.next && state.next.type == 'pass') delete states[state.id] }) obj.states.forEach(state => { if (state.next) state.next = state.next.id if (state.then) state.then = state.then.id if (state.else) state.else = state.else.id - if (state.frame && state.frame.catch) state.frame.catch = state.frame.catch.id + if (state.catch) state.catch = state.catch.id }) obj.states.forEach(state => delete state.id) const composition = { entry, states, exit } @@ -394,9 +403,11 @@ function main(params) { if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) state = params.value === true ? json.then : json.else break - case 'enter': - if (!isObject(json.frame)) return badRequest(`State ${current} specified an invalid frame`) - stack.unshift(json.frame) + case 'try': + stack.unshift({ catch: json.catch }) + break + case 'let': + stack.unshift({ let: json.let }) break case 'exit': if (stack.length === 0) return badRequest(`State ${current} attempted to pop from an empty stack`) @@ -435,6 +446,7 @@ function main(params) { inspect() break case 'pass': + inspect() break default: return badRequest(`State ${current} has an unknown type`) diff --git a/test/test.js b/test/test.js index fab86c7..3977609 100644 --- a/test/test.js +++ b/test/test.js @@ -307,11 +307,21 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) - it('while must throw even without iterations', function () { + it('try must throw', function () { + return invoke(composer.try(composer.try(), error => ({ message: error.error })), { error: 'foo' }) + .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) + }) + + it('while must throw', function () { return invoke(composer.try(composer.while(composer.value(false)), error => ({ message: error.error })), { error: 'foo' }) .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) + it('if must throw', function () { + return invoke(composer.try(composer.if(composer.value(false)), error => ({ message: error.error })), { error: 'foo' }) + .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) + }) + it('too many arguments', function () { try { invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) From 77de231f167d4f7f8554edebd3e515c11d35e25a Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 1 Feb 2018 13:19:06 -0500 Subject: [PATCH 29/62] Compiler tweaks --- composer.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/composer.js b/composer.js index 1fea051..10f5189 100644 --- a/composer.js +++ b/composer.js @@ -279,12 +279,16 @@ class Composer { while (state.catch && state.catch.type === 'pass' && state.catch.next && state.catch.next.type === 'pass') state.catch = state.catch.next } }) - obj.states.forEach(state => { if (state.type === 'pass' && state.next && state.next.type == 'pass') delete states[state.id] }) obj.states.forEach(state => { + if (state.type === 'pass' && state.next && state.next.type == 'pass') delete states[state.id] if (state.next) state.next = state.next.id - if (state.then) state.then = state.then.id - if (state.else) state.else = state.else.id - if (state.catch) state.catch = state.catch.id + if (state.type === 'choice') { + if (state.then) state.then = state.then.id + if (state.else) state.else = state.else.id + } + if (state.type === 'try') { + if (state.catch) state.catch = state.catch.id + } }) obj.states.forEach(state => delete state.id) const composition = { entry, states, exit } From 5130fcebca80c6fe7e769461afbd68de706d286f Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 07:39:39 -0500 Subject: [PATCH 30/62] New IR --- composer.js | 439 ++++++++++++++++++++++++++------------------------- test/test.js | 140 +++++++++++----- 2 files changed, 326 insertions(+), 253 deletions(-) diff --git a/composer.js b/composer.js index 10f5189..d0feed1 100644 --- a/composer.js +++ b/composer.js @@ -22,33 +22,61 @@ const fs = require('fs') const os = require('os') const path = require('path') const util = require('util') -const clone = require('clone') const openwhisk = require('openwhisk') class ComposerError extends Error { - constructor(message, cause) { - super(message) - if (typeof cause !== 'undefined') { - const index = this.stack.indexOf('\n') - this.stack = this.stack.substring(0, index) + '\nCause: ' + util.inspect(cause) + this.stack.substring(index) - } + constructor(message, argument) { + super(message + (typeof argument !== 'undefined' ? '\nArgument: ' + util.inspect(argument) : '')) } } -// build a composition with a single state -function singleton(entry, Manifest = []) { - return { entry, states: [entry], exit: entry, Manifest } +function validate(options) { + if (typeof options === 'undefined') return + if (typeof options !== 'object' || Array.isArray(options) || options === null) throw new ComposerError('Invalid options', options) + options = JSON.stringify(options) + if (options === '{}') return + return JSON.parse(options) } -// chain two compositions, mutating front -function chain(front, back) { - if (front.type) front = singleton(front) - if (back.type) back = singleton(back) - front.states.push(...back.states) - front.exit.next = back.entry - front.exit = back.exit - front.Manifest.push(...back.Manifest) - return front +let wsk + +class Composition { + constructor(composition, actions = []) { + Object.keys(composition).forEach(key => { + if (composition[key] instanceof Composition) { + // TODO: check for duplicate entries + actions.push(...composition[key].actions || []) + composition[key] = composition[key].composition + } + }) + if (Array.isArray(composition)) { + composition = composition.reduce((composition, component) => { + if (Array.isArray(component)) composition.push(...component); else composition.push(component) + return composition + }, []) + if (composition.length === 1) composition = composition[0] + } + if (actions.length > 0) this.actions = actions + this.composition = composition + } + + named(name) { + if (arguments.length > 1) throw new ComposerError('Too many arguments') + if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) + const actions = [] + if ((this.actions || []).findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) + const code = `${__init__}\n${__eval__}${main}\nconst __composition__ = __init__(${JSON.stringify(this.composition, null, 4)})\n` + actions.push(...this.actions || [], { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: this.composition }] } }) + return new Composition({ type: 'action', name }, actions) + } + + deploy() { + if (arguments.length > 0) throw new ComposerError('Too many arguments') + if (this.composition.type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') + let i = 0 + return this.actions.reduce((promise, action) => + promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(action).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) + } } class Composer { @@ -73,176 +101,122 @@ class Composer { } } catch (error) { } - this.wsk = openwhisk(Object.assign({ apihost, api_key }, options)) + this.wsk = wsk = openwhisk(Object.assign({ apihost, api_key }, options)) this.seq = this.sequence } task(obj) { if (arguments.length > 1) throw new ComposerError('Too many arguments') - if (obj == null) { - // case null: identity function (must throw errors if any) - return singleton({ type: 'pass', id: {} }) - } else if (Array.isArray(obj) && obj.length > 0 && typeof obj.slice(-1)[0].name === 'string') { - // case array: last action in the array - return singleton({ type: 'action', action: obj.slice(-1)[0].name }, clone(obj)) - } else if (typeof obj === 'object' && typeof obj.entry === 'object' && Array.isArray(obj.states) && typeof obj.exit === 'object' && Array.isArray(obj.Manifest)) { - // case object: composition - return clone(obj) - } else if (typeof obj === 'function') { - // case function: inline function - return this.function(obj) - } else if (typeof obj === 'string') { - // case string: action - return this.action(obj) - } else { - // error - throw new ComposerError('Invalid argument', obj) - } + if (obj == null) return this.seq() + if (obj instanceof Composition) return obj + if (typeof obj === 'function') return this.function(obj) + if (typeof obj === 'string') return this.action(obj) + throw new ComposerError('Invalid argument', obj) } - sequence() { // varargs - if (arguments.length == 0) return this.task() - return Array.prototype.map.call(arguments, x => this.task(x), this).reduce(chain) + sequence() { // varargs, no options + return new Composition(Array.prototype.map.call(arguments, obj => this.task(obj), this)) } - if(test, consequent, alternate, options = {}) { + if(test, consequent, alternate, options) { if (arguments.length > 4) throw new ComposerError('Too many arguments') - if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) - const id = {} - test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) - consequent = options.nosave ? this.task(consequent) : chain({ type: 'pop', id, label: 'then' }, this.task(consequent)) - alternate = options.nosave ? this.task(alternate) : chain({ type: 'pop', id, label: 'else' }, this.task(alternate)) - const exit = { type: 'pass', id } - const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } - test.states.push(choice) - test.states.push(...consequent.states) - test.states.push(...alternate.states) - test.states.push(exit) - test.exit.next = choice - consequent.exit.next = exit - alternate.exit.next = exit - test.exit = exit - test.Manifest.push(...consequent.Manifest) - test.Manifest.push(...alternate.Manifest) - return test + return new Composition({ type: 'if', test: this.task(test), consequent: this.task(consequent), alternate: this.task(alternate), options: validate(options) }) } - while(test, body, options = {}) { + while(test, body, options) { if (arguments.length > 3) throw new ComposerError('Too many arguments') - if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) - const id = {} - test = options.nosave ? this.task(test) : chain({ type: 'push', id }, this.task(test)) - const consequent = options.nosave ? this.task(body) : chain({ type: 'pop', id, label: 'then' }, this.task(body)) - const alternate = options.nosave ? this.task() : chain({ type: 'pop', id, label: 'else' }, this.task()) - const choice = { type: 'choice', then: consequent.entry, else: alternate.entry, id } - test.states.push(choice) - test.states.push(...consequent.states) - test.states.push(...alternate.states) - test.exit.next = choice - consequent.exit.next = test.entry - test.exit = alternate.exit - test.Manifest.push(...consequent.Manifest) - test.states.push(...alternate.Manifest) - return test + return new Composition({ type: 'while', test: this.task(test), body: this.task(body), options: validate(options) }) } - try(body, handler) { - if (arguments.length > 2) throw new ComposerError('Too many arguments') - const id = {} - handler = this.task(handler) - const exit = { type: 'pass', id } - body = [{ type: 'try', id, catch: handler.entry }, this.task(body), { type: 'exit', id }, exit].reduce(chain) - body.states.push(...handler.states) - handler.exit.next = exit - body.Manifest.push(...handler.Manifest) - return body + try(body, handler, options) { + if (arguments.length > 3) throw new ComposerError('Too many arguments') + return new Composition({ type: 'try', body: this.task(body), handler: this.task(handler), options: validate(options) }) } - finally(body, handler) { - if (arguments.length > 2) throw new ComposerError('Too many arguments') - const id = {} - handler = this.task(handler) - return [{ type: 'try', id, catch: handler.entry }, this.task(body), { type: 'exit', id }, handler].reduce(chain) + finally(body, finalizer, options) { + if (arguments.length > 3) throw new ComposerError('Too many arguments') + return new Composition({ type: 'finally', body: this.task(body), finalizer: this.task(finalizer), options: validate(options) }) } - let(obj) { // varargs - if (typeof obj !== 'object' || obj === null) throw new ComposerError('Invalid argument', obj) - const id = {} - return [{ type: 'let', id, let: JSON.parse(JSON.stringify(obj)) }, this.sequence(...Array.prototype.slice.call(arguments, 1)), { type: 'exit', id }].reduce(chain) + let(declarations) { // varargs, no options + if (typeof declarations !== 'object' || declarations === null) throw new ComposerError('Invalid argument', declarations) + return new Composition({ type: 'let', declarations: JSON.parse(JSON.stringify(declarations)), body: this.seq(...Array.prototype.slice.call(arguments, 1)) }) } - value(v) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - if (typeof v === 'function') throw new ComposerError('Invalid argument', v) - return singleton({ type: 'value', value: typeof v === 'undefined' ? {} : JSON.parse(JSON.stringify(v)) }) - + literal(value, options) { + if (arguments.length > 2) throw new ComposerError('Too many arguments') + if (typeof value === 'function') throw new ComposerError('Invalid argument', value) + return new Composition({ type: 'literal', value: typeof value === 'undefined' ? {} : JSON.parse(JSON.stringify(value)), options: validate(options) }) } - function(f, options = {}) { + function(exec, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) - if (typeof f === 'function') { - const code = `${f}` - if (code.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', f) - f = code + if (typeof exec === 'function') { + exec = `${exec}` + if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', exec) } - if (typeof f !== 'string') throw new ComposerError('Invalid argument', f) - return singleton({ type: 'function', function: f, helper: options.helper }) - + if (typeof exec === 'string') { + exec = { kind: 'nodejs:default', exec } + } + if (typeof exec !== 'object' || exec === null) throw new ComposerError('Invalid argument', exec) + return new Composition({ type: 'function', exec, options: validate(options) }) } - action(name, options = {}) { + action(name, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) - let Manifest = [] - if (options.filename) { // read action code from file - Manifest = [{ name, action: fs.readFileSync(options.filename, { encoding: 'utf8' }) }] - } else if (options.sequence) { // native sequence + let exec + if (options && Array.isArray(options.sequence)) { // native sequence const components = options.sequence.map(a => a.indexOf('/') == -1 ? `/_/${a}` : a) - Manifest = [{ name, action: { exec: { kind: 'sequence', components } } }] - } else if (typeof options.action === 'string' || typeof options.action === 'object' && options.action !== null) { - Manifest = [{ name, action: options.action }] - } else if (typeof options.action === 'function') { - const action = `${options.action}` - if (action.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) - Manifest = [{ name, action }] + exec = { kind: 'sequence', components } + delete options.sequence + } + if (options && typeof options.filename === 'string') { // read action code from file + options.action = fs.readFileSync(options.filename, { encoding: 'utf8' }) + delete options.filename + } + if (options && typeof options.action === 'function') { + options.action = `${options.action}` + if (options.action.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) + } + if (options && typeof options.action === 'string') { + options.action = { kind: 'nodejs:default', code: options.action } } - return singleton({ type: 'action', action: name }) + if (options && typeof options.action === 'object' && options.action !== null) { + exec = options.action + delete options.action + } + return new Composition({ type: 'action', name, options: validate(options) }, exec ? [{ name, action: { exec } }] : []) } - retain(body, options = {}) { + retain(body, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (typeof options !== 'object' || options === null) throw new ComposerError('Invalid argument', options) - if (typeof options.filter === 'function') { + if (options && typeof options.filter === 'function') { // return { params: filter(params), result: body(params) } - return this.sequence(this.retain(options.filter), - this.retain(this.finally(this.function(({ params }) => params, { helper: 'retain_4' }), body), { field: 'result', catch: options.catch })) + const filter = options.filter + delete options.filter + options.field = 'result' + return this.seq(this.retain(filter), this.retain(this.finally(this.function(({ params }) => params, { helper: 'retain_3' }), body), options)) } - if (options.catch) { + if (options && typeof options.catch === 'boolean' && options.catch) { // return { params, result: body(params) } even if result is an error - return this.sequence( - this.retain( - this.try( - this.sequence(body, this.function(result => ({ result }), { helper: 'retain_1' })), - this.function(result => ({ result }), { helper: 'retain_3' })), - { field: options.field }), + delete options.catch + return this.seq( + this.retain(this.finally(body, this.function(result => ({ result }), { helper: 'retain_1' })), options), this.function(({ params, result }) => ({ params, result: result.result }), { helper: 'retain_2' })) - } else { - // return { params, result: body(params) } if no error, otherwise body(params) - const id = {} - return [{ type: 'push', id, field: options.field }, this.task(body), { type: 'pop', id, collect: true }].reduce(chain) } + // return new Composition({ params, result: body(params) } if no error, otherwise body(params) + return new Composition({ type: 'retain', body: this.task(body), options: validate(options) }) } - repeat(count) { // varargs + repeat(count) { // varargs, no options if (typeof count !== 'number') throw new ComposerError('Invalid argument', count) - return this.let({ count }, this.while(this.function(() => count-- > 0, { helper: 'repeat_1' }), this.sequence(...Array.prototype.slice.call(arguments, 1)))) + return this.let({ count }, this.while(this.function(() => count-- > 0, { helper: 'repeat_1' }), this.seq(...Array.prototype.slice.call(arguments, 1)))) } - retry(count) { // varargs + retry(count) { // varargs, no options if (typeof count !== 'number') throw new ComposerError('Invalid argument', count) - const attempt = this.retain(this.sequence(...Array.prototype.slice.call(arguments, 1)), { catch: true }) + const attempt = this.retain(this.seq(...Array.prototype.slice.call(arguments, 1)), { catch: true }) return this.let({ count }, attempt, this.while( @@ -250,67 +224,102 @@ class Composer { this.finally(this.function(({ params }) => params, { helper: 'retry_2' }), attempt)), this.function(({ result }) => result, { helper: 'retry_3' })) } +} - // produce action code - compile(name, obj, filename) { - if (arguments.length > 3) throw new ComposerError('Too many arguments') - if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) - if (typeof filename !== 'undefined' && typeof filename !== 'string') throw new ComposerError('Invalid argument', filename) - obj = this.task(obj) - const states = {} - let entry - let exit - let count = 0 - obj.states.forEach(state => { - if (typeof state.id === 'undefined') state.id = {} - if (typeof state.id.id === 'undefined') state.id.id = count++ - const id = state.type + '_' + (state.label ? state.label + '_' : '') + state.id.id - delete state.label - states[id] = state - state.id = id - if (state === obj.entry) entry = id - if (state === obj.exit) exit = id - while (state.next && state.next.type === 'pass' && state.next.next && state.next.next.type === 'pass') state.next = state.next.next - if (state.type === 'choice') { - while (state.then && state.then.type === 'pass' && state.then.next && state.then.next.type === 'pass') state.then = state.then.next - while (state.else && state.else.type === 'pass' && state.else.next && state.else.next.type === 'pass') state.else = state.else.next - } - if (state.type === 'try') { - while (state.catch && state.catch.type === 'pass' && state.catch.next && state.catch.next.type === 'pass') state.catch = state.catch.next - } - }) - obj.states.forEach(state => { - if (state.type === 'pass' && state.next && state.next.type == 'pass') delete states[state.id] - if (state.next) state.next = state.next.id - if (state.type === 'choice') { - if (state.then) state.then = state.then.id - if (state.else) state.else = state.else.id - } - if (state.type === 'try') { - if (state.catch) state.catch = state.catch.id - } - }) - obj.states.forEach(state => delete state.id) - const composition = { entry, states, exit } - const action = `const __eval__ = main => eval(main)\n${main}\nconst __composition__ = ${JSON.stringify(composition, null, 4)}\n` - if (filename) fs.writeFileSync(filename, action, { encoding: 'utf8' }) - obj.Manifest.push({ name, action, annotations: { conductor: composition } }) - return obj.Manifest +module.exports = options => new Composer(options) + +// conductor action + +function __init__(composition) { + class FSM { + constructor(exit) { + this.states = [exit] + this.exit = exit + } } - // deploy actions, return count of successfully deployed actions - deploy(actions) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - if (!Array.isArray(actions)) throw new ComposerError('Invalid argument', array) - // clone array as openwhisk mutates the actions - return clone(actions).reduce( - (promise, action) => promise.then(i => this.wsk.actions.update(action).then(_ => i + 1, err => { console.error(err); return i })), Promise.resolve(0)) + function chain(front, back) { + if (!(front instanceof FSM)) front = new FSM(front) + if (!(back instanceof FSM)) back = new FSM(back) + front.states.push(...back.states) + front.exit.next = back.states[0] + front.exit = back.exit + return front } -} -module.exports = options => new Composer(options) + function compile(json, path = '') { + if (Array.isArray(json)) { + if (json.length === 0) return new FSM({ type: 'pass', path }) + return json.map((json, index) => compile(json, path + ':' + index)).reduce(chain) + } + const options = json.options || {} + switch (json.type) { + case 'action': + return new FSM({ type: json.type, name: json.name, path }) + case 'function': + return new FSM({ type: json.type, exec: json.exec, path }) + case 'literal': + return new FSM({ type: json.type, value: json.value, path }) + case 'finally': + var body = compile(json.body, path + ':1') + const finalizer = compile(json.finalizer, path + ':2') + return [{ type: 'try', catch: finalizer.states[0], path }, body, { type: 'exit', path }, finalizer].reduce(chain) + case 'let': + var body = compile(json.body, path + ':1') + return [{ type: 'let', let: json.declarations, path }, body, { type: 'exit', path }].reduce(chain) + case 'retain': + var body = compile(json.body, path + ':1') + var fsm = [{ type: 'push', path }, body, { type: 'pop', collect: true, path }].reduce(chain) + if (options.field) fsm.states[0].field = options.field + return fsm + case 'try': + var body = compile(json.body, path + ':1') + const handler = compile(json.handler, path + ':2') + var fsm = [{ type: 'try', catch: handler.states[0], path }, body, { type: 'pass', path }].reduce(chain) + fsm.states.push(...handler.states) + handler.exit.next = fsm.exit + return fsm + case 'if': + var consequent = chain(compile(json.consequent, path + ':2'), { type: 'pass', path }) + var alternate = compile(json.alternate, path + ':3') + if (!options.nosave) consequent = chain({ type: 'pop', path }, consequent) + if (!options.nosave) alternate = chain({ type: 'pop', path }, alternate) + var fsm = chain(compile(json.test, path + ':1'), { type: 'choice', then: consequent.states[0], else: alternate.states[0], path }) + if (!options.nosave) fsm = chain({ type: 'push', path }, fsm) + fsm.states.push(...consequent.states) + fsm.states.push(...alternate.states) + fsm.exit = alternate.exit.next = consequent.exit + return fsm + case 'while': + var consequent = compile(json.body, path + ':2') + var alternate = new FSM({ type: 'pass', path }) + if (!options.nosave) consequent = chain({ type: 'pop', path }, consequent) + if (!options.nosave) alternate = chain({ type: 'pop', path }, alternate) + var fsm = chain(compile(json.test, path + ':1'), { type: 'choice', then: consequent.states[0], else: alternate.states[0], path }) + if (!options.nosave) fsm = chain({ type: 'push', path }, fsm) + fsm.states.push(...consequent.states) + fsm.states.push(...alternate.states) + consequent.exit.next = fsm.states[0] + fsm.exit = alternate.exit + return fsm + } + } -// conductor action + const fsm = compile(composition) + + fsm.states.forEach(state => { + Object.keys(state).forEach(key => { + if (state[key].type) { + state[key] = fsm.states.indexOf(state[key]) + } + }) + }) + fsm.exit = fsm.states.indexOf(fsm.exit) + + return fsm +} + +function __eval__(main) { return eval(main) } function main(params) { const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) @@ -331,20 +340,20 @@ function main(params) { // do invocation function invoke(params) { // initial state and stack - let state = __composition__.entry + let state = 0 let stack = [] // check parameters - if (typeof __composition__.entry !== 'string') return badRequest('The composition has no entry field of type string') - if (!isObject(__composition__.states)) return badRequest('The composition has no states field of type object') - if (typeof __composition__.exit !== 'string') return badRequest('The composition has no exit field of type string') + // if (typeof __composition__.entry !== 'string') return badRequest('The composition has no entry field of type string') + // if (!isObject(__composition__.states)) return badRequest('The composition has no states field of type object') + // if (typeof __composition__.exit !== 'string') return badRequest('The composition has no exit field of type string') // restore state and stack when resuming if (typeof params.$resume !== 'undefined') { if (!isObject(params.$resume)) return badRequest('The type of optional $resume parameter must be object') state = params.$resume.state stack = params.$resume.stack - if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('The type of optional $resume.state parameter must be string') + // if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('The type of optional $resume.state parameter must be string') if (!Array.isArray(stack)) return badRequest('The type of $resume.stack must be an array') delete params.$resume inspect() // handle error objects when resuming @@ -357,7 +366,7 @@ function main(params) { params = { error: params.error } // discard all fields but the error field state = undefined // abort unless there is a handler in the stack while (stack.length > 0) { - if (state = stack.shift().catch) break + if (typeof (state = stack.shift().catch) === 'number') break } } } @@ -386,7 +395,7 @@ function main(params) { while (true) { // final state, return composition result - if (!state) { + if (typeof state === 'undefined') { console.log(`Entering final state`) console.log(JSON.stringify(params)) if (params.error) return params; else return { params } @@ -398,13 +407,13 @@ function main(params) { if (!isObject(__composition__.states[state])) return badRequest(`State ${state} definition is missing`) const json = __composition__.states[state] // json definition for current state const current = state // current state for error messages - if (json.type !== 'choice' && typeof json.next !== 'string' && state !== __composition__.exit) return badRequest(`State ${state} has no next field`) + // if (json.type !== 'choice' && typeof json.next !== 'string' && state !== __composition__.exit) return badRequest(`State ${state} has no next field`) state = json.next // default next state - + console.log(json) switch (json.type) { case 'choice': - if (typeof json.then !== 'string') return badRequest(`State ${current} has no then field`) - if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) + // if (typeof json.then !== 'string') return badRequest(`State ${current} has no then field`) + // if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) state = params.value === true ? json.then : json.else break case 'try': @@ -427,19 +436,19 @@ function main(params) { params = json.collect ? { params: stack.shift().params, result: params } : stack.shift().params break case 'action': - if (typeof json.action !== 'string') return badRequest(`State ${current} specifies an invalid action`) - return { action: json.action, params, state: { $resume: { state, stack } } } // invoke continuation + if (typeof json.name !== 'string') return badRequest(`State ${current} specifies an invalid action`) + return { action: json.name, params, state: { $resume: { state, stack } } } // invoke continuation break - case 'value': + case 'literal': if (typeof json.value === 'undefined') return badRequest(`State ${current} specifies an invalid value`) params = json.value inspect() break case 'function': - if (typeof json.function !== 'string') return badRequest(`State ${current} specifies an invalid function`) + if (typeof json.exec.exec !== 'string') return badRequest(`State ${current} specifies an invalid function`) let result try { - result = run(json.function) + result = run(json.exec.exec) } catch (error) { console.error(error) result = { error: `An exception was caught at state ${current} (see log for details)` } diff --git a/test/test.js b/test/test.js index 3977609..0edc5a2 100644 --- a/test/test.js +++ b/test/test.js @@ -3,17 +3,17 @@ const composer = require('../composer')() const name = 'TestAction' // compile, deploy, and blocking invoke -const invoke = (task, params = {}, blocking = true) => composer.deploy(composer.compile(name, task)).then(() => composer.wsk.actions.invoke({ name, params, blocking })) +const invoke = (task, params = {}, blocking = true) => task.named(name).deploy().then(() => composer.wsk.actions.invoke({ name, params, blocking })) describe('composer', function () { this.timeout(20000) before('deploy test actions', function () { - return composer.deploy( - [{ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' }, - { name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' }, - { name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' }, - { name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' }]) + return Promise.all([ + composer.action('DivideByTwo', { action: 'function main({n}) { return { n: n / 2 } }' }).deploy(), + composer.action('TripleAndIncrement', { action: 'function main({n}) { return { n: n * 3 + 1 } }' }).deploy(), + composer.action('isNotOne', { action: 'function main({n}) { return { value: n != 1 } }' }).deploy(), + composer.action('isEven', { action: 'function main({n}) { return { value: n % 2 == 0 } }' }).deploy()]) }) describe('blocking invocations', function () { @@ -31,16 +31,16 @@ describe('composer', function () { invoke(composer.function('foo', 'bar')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid options')) } }) - it('invalid name argument', function () { + it('invalid argument', function () { try { invoke(composer.function(42)) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid argument')) } }) @@ -49,26 +49,44 @@ describe('composer', function () { invoke(composer.function('foo', {}, 'bar')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) - describe('values', function () { + describe('literals', function () { it('true', function () { - return invoke(composer.value(true)).then(activation => assert.deepEqual(activation.response.result, { value: true })) + return invoke(composer.literal(true)).then(activation => assert.deepEqual(activation.response.result, { value: true })) }) it('42', function () { - return invoke(composer.value(42)).then(activation => assert.deepEqual(activation.response.result, { value: 42 })) + return invoke(composer.literal(42)).then(activation => assert.deepEqual(activation.response.result, { value: 42 })) + }) + + it('invalid options', function () { + try { + invoke(composer.literal('foo', 'bar')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid options')) + } + }) + + it('invalid argument', function () { + try { + invoke(composer.literal(invoke)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } }) it('too many arguments', function () { try { - invoke(composer.value('foo', 'bar')) + invoke(composer.literal('foo', {}, 'bar')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -98,21 +116,21 @@ describe('composer', function () { return invoke(composer.function('({ n }) => n % 2 === 0'), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) }) - it('invalid options argument', function () { + it('invalid options', function () { try { invoke(composer.function(() => n, 'foo')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid options')) } }) - it('invalid function argument', function () { + it('invalid argument', function () { try { invoke(composer.function(42)) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid argument')) } }) @@ -121,7 +139,7 @@ describe('composer', function () { invoke(composer.function(() => n, {}, () => { })) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -155,7 +173,7 @@ describe('composer', function () { invoke(composer.task(false)) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid argument')) } }) @@ -164,7 +182,7 @@ describe('composer', function () { invoke(composer.task(42)) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid argument')) } }) @@ -173,7 +191,7 @@ describe('composer', function () { invoke(composer.task({ foo: 'bar' })) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid argument')) } }) }) @@ -183,7 +201,7 @@ describe('composer', function () { invoke(composer.task('foo', 'bar')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -242,12 +260,12 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { value: false, else: true })) }) - it('invalid options argument', function () { + it('invalid options', function () { try { invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', 'TripleAndIncrement')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid options')) } }) @@ -256,7 +274,7 @@ describe('composer', function () { invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', {}, 'TripleAndIncrement')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -277,12 +295,12 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) }) - it('invalid options argument', function () { + it('invalid options', function () { try { invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid options')) } }) @@ -291,7 +309,7 @@ describe('composer', function () { invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), {}, ({ n }) => ({ n: n - 1 })), { n: 4 }) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -313,21 +331,30 @@ describe('composer', function () { }) it('while must throw', function () { - return invoke(composer.try(composer.while(composer.value(false)), error => ({ message: error.error })), { error: 'foo' }) + return invoke(composer.try(composer.while(composer.literal(false)), error => ({ message: error.error })), { error: 'foo' }) .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) it('if must throw', function () { - return invoke(composer.try(composer.if(composer.value(false)), error => ({ message: error.error })), { error: 'foo' }) + return invoke(composer.try(composer.if(composer.literal(false)), error => ({ message: error.error })), { error: 'foo' }) .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) }) - it('too many arguments', function () { + it('invalid options', function () { try { invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Invalid options')) + } + }) + + it('too many arguments', function () { + try { + invoke(composer.try('isNotOne', 'isNotOne', {}, 'isNotOne')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -343,12 +370,21 @@ describe('composer', function () { .then(activation => assert.deepEqual(activation.response.result, { params: { error: 'foo' } })) }) - it('too many arguments', function () { + it('invalid options', function () { try { invoke(composer.finally('isNotOne', 'isNotOne', 'isNotOne')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Invalid options')) + } + }) + + it('too many arguments', function () { + try { + invoke(composer.finally('isNotOne', 'isNotOne', {}, 'isNotOne')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -378,6 +414,15 @@ describe('composer', function () { return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x), ({ value }) => value + x)) .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) }) + + it('invalid argument', function () { + try { + invoke(composer.let(invoke)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) }) describe('retain', function () { @@ -431,7 +476,7 @@ describe('composer', function () { invoke(composer.retain('isNotOne', 'isNotOne')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Invalid argument') + assert.ok(error.message.startsWith('Invalid options')) } }) @@ -440,7 +485,7 @@ describe('composer', function () { invoke(composer.retain('isNotOne', {}, 'isNotOne')) assert.fail() } catch (error) { - assert.equal(error, 'Error: Too many arguments') + assert.ok(error.message.startsWith('Too many arguments')) } }) }) @@ -450,6 +495,15 @@ describe('composer', function () { return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 }) .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) }) + + it('invalid argument', function () { + try { + invoke(composer.repeat('foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) }) describe('retry', function () { @@ -461,6 +515,16 @@ describe('composer', function () { it('failure', function () { return invoke(composer.let({ x: 2 }, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42))) .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result.error, 'foo')) + + }) + + it('invalid argument', function () { + try { + invoke(composer.retry('foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } }) }) }) From ff44bdef9d22127945241eeec2201a30a0b15e3f Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 10:26:50 -0500 Subject: [PATCH 31/62] Simplify FSM --- composer.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/composer.js b/composer.js index d0feed1..f3177a4 100644 --- a/composer.js +++ b/composer.js @@ -64,7 +64,7 @@ class Composition { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) const actions = [] - if ((this.actions || []).findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) + if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) const code = `${__init__}\n${__eval__}${main}\nconst __composition__ = __init__(${JSON.stringify(this.composition, null, 4)})\n` actions.push(...this.actions || [], { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: this.composition }] } }) return new Composition({ type: 'action', name }, actions) @@ -234,16 +234,18 @@ function __init__(composition) { class FSM { constructor(exit) { this.states = [exit] - this.exit = exit + } + + last() { + return this.states[this.states.length - 1] } } function chain(front, back) { if (!(front instanceof FSM)) front = new FSM(front) if (!(back instanceof FSM)) back = new FSM(back) + front.last().next = back.states[0] front.states.push(...back.states) - front.exit.next = back.states[0] - front.exit = back.exit return front } @@ -274,21 +276,21 @@ function __init__(composition) { return fsm case 'try': var body = compile(json.body, path + ':1') - const handler = compile(json.handler, path + ':2') - var fsm = [{ type: 'try', catch: handler.states[0], path }, body, { type: 'pass', path }].reduce(chain) + const handler = chain(compile(json.handler, path + ':2'),{ type: 'pass', path }) + var fsm = [{ type: 'try', catch: handler.states[0], path }, body].reduce(chain) + fsm.last().next = handler.last() fsm.states.push(...handler.states) - handler.exit.next = fsm.exit return fsm case 'if': - var consequent = chain(compile(json.consequent, path + ':2'), { type: 'pass', path }) - var alternate = compile(json.alternate, path + ':3') + var consequent = compile(json.consequent, path + ':2') + var alternate = chain(compile(json.alternate, path + ':3'), { type: 'pass', path }) if (!options.nosave) consequent = chain({ type: 'pop', path }, consequent) if (!options.nosave) alternate = chain({ type: 'pop', path }, alternate) var fsm = chain(compile(json.test, path + ':1'), { type: 'choice', then: consequent.states[0], else: alternate.states[0], path }) if (!options.nosave) fsm = chain({ type: 'push', path }, fsm) + consequent.last().next = alternate.last() fsm.states.push(...consequent.states) fsm.states.push(...alternate.states) - fsm.exit = alternate.exit.next = consequent.exit return fsm case 'while': var consequent = compile(json.body, path + ':2') @@ -297,10 +299,9 @@ function __init__(composition) { if (!options.nosave) alternate = chain({ type: 'pop', path }, alternate) var fsm = chain(compile(json.test, path + ':1'), { type: 'choice', then: consequent.states[0], else: alternate.states[0], path }) if (!options.nosave) fsm = chain({ type: 'push', path }, fsm) + consequent.last().next = fsm.states[0] fsm.states.push(...consequent.states) fsm.states.push(...alternate.states) - consequent.exit.next = fsm.states[0] - fsm.exit = alternate.exit return fsm } } @@ -314,7 +315,6 @@ function __init__(composition) { } }) }) - fsm.exit = fsm.states.indexOf(fsm.exit) return fsm } From 38c6ad3b28121ea85519cf6a3e2b7ac8abc61ee7 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 12:53:33 -0500 Subject: [PATCH 32/62] Replace FSM with simple array --- composer.js | 96 +++++++++++++++++++++++------------------------------ 1 file changed, 42 insertions(+), 54 deletions(-) diff --git a/composer.js b/composer.js index f3177a4..1904a90 100644 --- a/composer.js +++ b/composer.js @@ -231,87 +231,75 @@ module.exports = options => new Composer(options) // conductor action function __init__(composition) { - class FSM { - constructor(exit) { - this.states = [exit] - } - - last() { - return this.states[this.states.length - 1] - } - } - function chain(front, back) { - if (!(front instanceof FSM)) front = new FSM(front) - if (!(back instanceof FSM)) back = new FSM(back) - front.last().next = back.states[0] - front.states.push(...back.states) + front.slice(-1)[0].next = back[0] + front.push(...back) return front } function compile(json, path = '') { if (Array.isArray(json)) { - if (json.length === 0) return new FSM({ type: 'pass', path }) - return json.map((json, index) => compile(json, path + ':' + index)).reduce(chain) + if (json.length === 0) return [{ type: 'pass', path }] + return json.map((json, index) => compile(json, path + '[' + index + ']')).reduce(chain) } const options = json.options || {} switch (json.type) { case 'action': - return new FSM({ type: json.type, name: json.name, path }) + return [{ type: json.type, name: json.name, path }] case 'function': - return new FSM({ type: json.type, exec: json.exec, path }) + return [{ type: json.type, exec: json.exec, path }] case 'literal': - return new FSM({ type: json.type, value: json.value, path }) + return [{ type: json.type, value: json.value, path }] case 'finally': - var body = compile(json.body, path + ':1') - const finalizer = compile(json.finalizer, path + ':2') - return [{ type: 'try', catch: finalizer.states[0], path }, body, { type: 'exit', path }, finalizer].reduce(chain) + var body = compile(json.body, path + '.body') + const finalizer = compile(json.finalizer, path + '.finalizer') + return [[{ type: 'try', catch: finalizer[0], path }], body, [{ type: 'exit', path }], finalizer].reduce(chain) case 'let': - var body = compile(json.body, path + ':1') - return [{ type: 'let', let: json.declarations, path }, body, { type: 'exit', path }].reduce(chain) + var body = compile(json.body, path + '.body') + return [[{ type: 'let', let: json.declarations, path }], body, [{ type: 'exit', path }]].reduce(chain) case 'retain': - var body = compile(json.body, path + ':1') - var fsm = [{ type: 'push', path }, body, { type: 'pop', collect: true, path }].reduce(chain) - if (options.field) fsm.states[0].field = options.field + var body = compile(json.body, path + '.body') + var fsm = [[{ type: 'push', path }], body, [{ type: 'pop', collect: true, path }]].reduce(chain) + if (options.field) fsm[0].field = options.field return fsm case 'try': - var body = compile(json.body, path + ':1') - const handler = chain(compile(json.handler, path + ':2'),{ type: 'pass', path }) - var fsm = [{ type: 'try', catch: handler.states[0], path }, body].reduce(chain) - fsm.last().next = handler.last() - fsm.states.push(...handler.states) + var body = compile(json.body, path + '.body') + const handler = chain(compile(json.handler, path + '.handler'), [{ type: 'pass', path }]) + var fsm = [[{ type: 'try', catch: handler[0], path }], body].reduce(chain) + fsm.slice(-1)[0].next = handler.slice(-1)[0] + fsm.push(...handler) return fsm case 'if': - var consequent = compile(json.consequent, path + ':2') - var alternate = chain(compile(json.alternate, path + ':3'), { type: 'pass', path }) - if (!options.nosave) consequent = chain({ type: 'pop', path }, consequent) - if (!options.nosave) alternate = chain({ type: 'pop', path }, alternate) - var fsm = chain(compile(json.test, path + ':1'), { type: 'choice', then: consequent.states[0], else: alternate.states[0], path }) - if (!options.nosave) fsm = chain({ type: 'push', path }, fsm) - consequent.last().next = alternate.last() - fsm.states.push(...consequent.states) - fsm.states.push(...alternate.states) + var consequent = compile(json.consequent, path + '.consequent') + var alternate = chain(compile(json.alternate, path + '.alternate'), [{ type: 'pass', path }]) + if (!options.nosave) consequent = chain([{ type: 'pop', path }], consequent) + if (!options.nosave) alternate = chain([{ type: 'pop', path }], alternate) + var fsm = chain(compile(json.test, path + '.test'), [{ type: 'choice', then: consequent[0], else: alternate[0], path }]) + if (!options.nosave) fsm = chain([{ type: 'push', path }], fsm) + consequent.slice(-1)[0].next = alternate.slice(-1)[0] + fsm.push(...consequent) + fsm.push(...alternate) return fsm case 'while': - var consequent = compile(json.body, path + ':2') - var alternate = new FSM({ type: 'pass', path }) - if (!options.nosave) consequent = chain({ type: 'pop', path }, consequent) - if (!options.nosave) alternate = chain({ type: 'pop', path }, alternate) - var fsm = chain(compile(json.test, path + ':1'), { type: 'choice', then: consequent.states[0], else: alternate.states[0], path }) - if (!options.nosave) fsm = chain({ type: 'push', path }, fsm) - consequent.last().next = fsm.states[0] - fsm.states.push(...consequent.states) - fsm.states.push(...alternate.states) + var consequent = compile(json.body, path + '.body') + var alternate = [{ type: 'pass', path }] + if (!options.nosave) consequent = chain([{ type: 'pop', path }], consequent) + if (!options.nosave) alternate = chain([{ type: 'pop', path }], alternate) + var fsm = chain(compile(json.test, path + ':test'), [{ type: 'choice', then: consequent[0], else: alternate[0], path }]) + if (!options.nosave) fsm = chain([{ type: 'push', path }], fsm) + consequent.slice(-1)[0].next = fsm[0] + fsm.push(...consequent) + fsm.push(...alternate) return fsm } } const fsm = compile(composition) - fsm.states.forEach(state => { + fsm.forEach(state => { Object.keys(state).forEach(key => { if (state[key].type) { - state[key] = fsm.states.indexOf(state[key]) + state[key] = fsm.indexOf(state[key]) } }) }) @@ -404,8 +392,8 @@ function main(params) { // process one state console.log(`Entering ${state}`) - if (!isObject(__composition__.states[state])) return badRequest(`State ${state} definition is missing`) - const json = __composition__.states[state] // json definition for current state + if (!isObject(__composition__[state])) return badRequest(`State ${state} definition is missing`) + const json = __composition__[state] // json definition for current state const current = state // current state for error messages // if (json.type !== 'choice' && typeof json.next !== 'string' && state !== __composition__.exit) return badRequest(`State ${state} has no next field`) state = json.next // default next state From cda8118e21adeb970d9dd5fd238ff5cf254369d4 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 13:15:41 -0500 Subject: [PATCH 33/62] Generate position-independent code --- composer.js | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/composer.js b/composer.js index 1904a90..9587429 100644 --- a/composer.js +++ b/composer.js @@ -232,7 +232,7 @@ module.exports = options => new Composer(options) function __init__(composition) { function chain(front, back) { - front.slice(-1)[0].next = back[0] + front.slice(-1)[0].next = 1 front.push(...back) return front } @@ -253,7 +253,9 @@ function __init__(composition) { case 'finally': var body = compile(json.body, path + '.body') const finalizer = compile(json.finalizer, path + '.finalizer') - return [[{ type: 'try', catch: finalizer[0], path }], body, [{ type: 'exit', path }], finalizer].reduce(chain) + var fsm = [[{ type: 'try', path }], body, [{ type: 'exit', path }], finalizer].reduce(chain) + fsm[0].catch = fsm.length - finalizer.length + return fsm case 'let': var body = compile(json.body, path + '.body') return [[{ type: 'let', let: json.declarations, path }], body, [{ type: 'exit', path }]].reduce(chain) @@ -265,8 +267,9 @@ function __init__(composition) { case 'try': var body = compile(json.body, path + '.body') const handler = chain(compile(json.handler, path + '.handler'), [{ type: 'pass', path }]) - var fsm = [[{ type: 'try', catch: handler[0], path }], body].reduce(chain) - fsm.slice(-1)[0].next = handler.slice(-1)[0] + var fsm = [[{ type: 'try', path }], body].reduce(chain) + fsm[0].catch = fsm.length + fsm.slice(-1)[0].next = handler.length fsm.push(...handler) return fsm case 'if': @@ -274,9 +277,9 @@ function __init__(composition) { var alternate = chain(compile(json.alternate, path + '.alternate'), [{ type: 'pass', path }]) if (!options.nosave) consequent = chain([{ type: 'pop', path }], consequent) if (!options.nosave) alternate = chain([{ type: 'pop', path }], alternate) - var fsm = chain(compile(json.test, path + '.test'), [{ type: 'choice', then: consequent[0], else: alternate[0], path }]) + var fsm = chain(compile(json.test, path + '.test'), [{ type: 'choice', then: 1, else: consequent.length + 1, path }]) if (!options.nosave) fsm = chain([{ type: 'push', path }], fsm) - consequent.slice(-1)[0].next = alternate.slice(-1)[0] + consequent.slice(-1)[0].next = alternate.length fsm.push(...consequent) fsm.push(...alternate) return fsm @@ -285,26 +288,16 @@ function __init__(composition) { var alternate = [{ type: 'pass', path }] if (!options.nosave) consequent = chain([{ type: 'pop', path }], consequent) if (!options.nosave) alternate = chain([{ type: 'pop', path }], alternate) - var fsm = chain(compile(json.test, path + ':test'), [{ type: 'choice', then: consequent[0], else: alternate[0], path }]) + var fsm = chain(compile(json.test, path + ':test'), [{ type: 'choice', then: 1, else: consequent.length + 1, path }]) if (!options.nosave) fsm = chain([{ type: 'push', path }], fsm) - consequent.slice(-1)[0].next = fsm[0] + consequent.slice(-1)[0].next = 1 - fsm.length - consequent.length fsm.push(...consequent) fsm.push(...alternate) return fsm } } - const fsm = compile(composition) - - fsm.forEach(state => { - Object.keys(state).forEach(key => { - if (state[key].type) { - state[key] = fsm.indexOf(state[key]) - } - }) - }) - - return fsm + return compile(composition) } function __eval__(main) { return eval(main) } @@ -392,20 +385,20 @@ function main(params) { // process one state console.log(`Entering ${state}`) - if (!isObject(__composition__[state])) return badRequest(`State ${state} definition is missing`) const json = __composition__[state] // json definition for current state + if (!json) return badRequest(`State ${state} definition is missing`) const current = state // current state for error messages // if (json.type !== 'choice' && typeof json.next !== 'string' && state !== __composition__.exit) return badRequest(`State ${state} has no next field`) - state = json.next // default next state + state = typeof json.next === 'undefined' ? undefined : current + json.next // default next state console.log(json) switch (json.type) { case 'choice': // if (typeof json.then !== 'string') return badRequest(`State ${current} has no then field`) // if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) - state = params.value === true ? json.then : json.else + state = current + (params.value ? json.then : json.else) break case 'try': - stack.unshift({ catch: json.catch }) + stack.unshift({ catch: current + json.catch }) break case 'let': stack.unshift({ let: json.let }) From 0bbf1d5529064304c76d4a2a974c8e908be2b2e7 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 13:29:31 -0500 Subject: [PATCH 34/62] Cleanup --- composer.js | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/composer.js b/composer.js index 9587429..66157f6 100644 --- a/composer.js +++ b/composer.js @@ -65,7 +65,7 @@ class Composition { if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) const actions = [] if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) - const code = `${__init__}\n${__eval__}${main}\nconst __composition__ = __init__(${JSON.stringify(this.composition, null, 4)})\n` + const code = `${__eval__}${main}\nconst __composition__ = (${init})(${JSON.stringify(this.composition, null, 4)})\n` actions.push(...this.actions || [], { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: this.composition }] } }) return new Composition({ type: 'action', name }, actions) } @@ -205,7 +205,8 @@ class Composer { this.retain(this.finally(body, this.function(result => ({ result }), { helper: 'retain_1' })), options), this.function(({ params, result }) => ({ params, result: result.result }), { helper: 'retain_2' })) } - // return new Composition({ params, result: body(params) } if no error, otherwise body(params) + if (options && typeof options.field !== 'undefined' && typeof options.field !== 'string') throw new ComposerError('Invalid options', options) + // return new Composition({ params, result: body(params) } if no error, otherwise body(params) return new Composition({ type: 'retain', body: this.task(body), options: validate(options) }) } @@ -230,7 +231,7 @@ module.exports = options => new Composer(options) // conductor action -function __init__(composition) { +function init(composition) { function chain(front, back) { front.slice(-1)[0].next = 1 front.push(...back) @@ -245,11 +246,11 @@ function __init__(composition) { const options = json.options || {} switch (json.type) { case 'action': - return [{ type: json.type, name: json.name, path }] + return [{ type: 'action', name: json.name, path }] case 'function': - return [{ type: json.type, exec: json.exec, path }] + return [{ type: 'function', exec: json.exec, path }] case 'literal': - return [{ type: json.type, value: json.value, path }] + return [{ type: 'literal', value: json.value, path }] case 'finally': var body = compile(json.body, path + '.body') const finalizer = compile(json.finalizer, path + '.finalizer') @@ -324,17 +325,12 @@ function main(params) { let state = 0 let stack = [] - // check parameters - // if (typeof __composition__.entry !== 'string') return badRequest('The composition has no entry field of type string') - // if (!isObject(__composition__.states)) return badRequest('The composition has no states field of type object') - // if (typeof __composition__.exit !== 'string') return badRequest('The composition has no exit field of type string') - // restore state and stack when resuming if (typeof params.$resume !== 'undefined') { if (!isObject(params.$resume)) return badRequest('The type of optional $resume parameter must be object') state = params.$resume.state stack = params.$resume.stack - // if (typeof state !== 'undefined' && typeof state !== 'string') return badRequest('The type of optional $resume.state parameter must be string') + if (typeof state !== 'undefined' && typeof state !== 'number') return badRequest('The type of optional $resume.state parameter must be number') if (!Array.isArray(stack)) return badRequest('The type of $resume.stack must be an array') delete params.$resume inspect() // handle error objects when resuming @@ -386,15 +382,11 @@ function main(params) { console.log(`Entering ${state}`) const json = __composition__[state] // json definition for current state - if (!json) return badRequest(`State ${state} definition is missing`) - const current = state // current state for error messages - // if (json.type !== 'choice' && typeof json.next !== 'string' && state !== __composition__.exit) return badRequest(`State ${state} has no next field`) + const current = state state = typeof json.next === 'undefined' ? undefined : current + json.next // default next state console.log(json) switch (json.type) { case 'choice': - // if (typeof json.then !== 'string') return badRequest(`State ${current} has no then field`) - // if (typeof json.else !== 'string') return badRequest(`State ${current} has no else field`) state = current + (params.value ? json.then : json.else) break case 'try': @@ -404,29 +396,24 @@ function main(params) { stack.unshift({ let: json.let }) break case 'exit': - if (stack.length === 0) return badRequest(`State ${current} attempted to pop from an empty stack`) + if (stack.length === 0) return internalError(`State ${current} attempted to pop from an empty stack`) stack.shift() break case 'push': - if (typeof json.field !== 'undefined' && typeof json.field !== 'string') return badRequest(`State ${current} is invalid`) stack.unshift(JSON.parse(JSON.stringify({ params: json.field ? params[json.field] : params }))) break case 'pop': - if (stack.length === 0) return badRequest(`State ${current} attempted to pop from an empty stack`) - if (typeof json.collect !== 'undefined' && typeof json.collect !== 'boolean') return badRequest(`State ${current} is invalid`) + if (stack.length === 0) return internalError(`State ${current} attempted to pop from an empty stack`) params = json.collect ? { params: stack.shift().params, result: params } : stack.shift().params break case 'action': - if (typeof json.name !== 'string') return badRequest(`State ${current} specifies an invalid action`) return { action: json.name, params, state: { $resume: { state, stack } } } // invoke continuation break case 'literal': - if (typeof json.value === 'undefined') return badRequest(`State ${current} specifies an invalid value`) params = json.value inspect() break case 'function': - if (typeof json.exec.exec !== 'string') return badRequest(`State ${current} specifies an invalid function`) let result try { result = run(json.exec.exec) @@ -443,7 +430,7 @@ function main(params) { inspect() break default: - return badRequest(`State ${current} has an unknown type`) + return internalError(`State ${current} has an unknown type`) } } } From b9ef812342d6065d04023f683f60c8da1c941491 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 13:55:36 -0500 Subject: [PATCH 35/62] Further cleanup --- composer.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/composer.js b/composer.js index 66157f6..24e2d5e 100644 --- a/composer.js +++ b/composer.js @@ -65,7 +65,7 @@ class Composition { if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) const actions = [] if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) - const code = `${__eval__}${main}\nconst __composition__ = (${init})(${JSON.stringify(this.composition, null, 4)})\n` + const code = `const __eval__ = main => eval(main)\nconst main = (${conductor})(${JSON.stringify(this.composition, null, 4)})\n` actions.push(...this.actions || [], { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: this.composition }] } }) return new Composition({ type: 'action', name }, actions) } @@ -140,13 +140,13 @@ class Composer { let(declarations) { // varargs, no options if (typeof declarations !== 'object' || declarations === null) throw new ComposerError('Invalid argument', declarations) - return new Composition({ type: 'let', declarations: JSON.parse(JSON.stringify(declarations)), body: this.seq(...Array.prototype.slice.call(arguments, 1)) }) + return new Composition({ type: 'let', declarations, body: this.seq(...Array.prototype.slice.call(arguments, 1)) }) } literal(value, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') if (typeof value === 'function') throw new ComposerError('Invalid argument', value) - return new Composition({ type: 'literal', value: typeof value === 'undefined' ? {} : JSON.parse(JSON.stringify(value)), options: validate(options) }) + return new Composition({ type: 'literal', value: typeof value === 'undefined' ? {} : value, options: validate(options) }) } function(exec, options) { @@ -206,7 +206,7 @@ class Composer { this.function(({ params, result }) => ({ params, result: result.result }), { helper: 'retain_2' })) } if (options && typeof options.field !== 'undefined' && typeof options.field !== 'string') throw new ComposerError('Invalid options', options) - // return new Composition({ params, result: body(params) } if no error, otherwise body(params) + // return new Composition({ params, result: body(params) } if no error, otherwise body(params) return new Composition({ type: 'retain', body: this.task(body), options: validate(options) }) } @@ -231,7 +231,7 @@ module.exports = options => new Composer(options) // conductor action -function init(composition) { +function conductor(composition) { function chain(front, back) { front.slice(-1)[0].next = 1 front.push(...back) @@ -298,12 +298,8 @@ function init(composition) { } } - return compile(composition) -} - -function __eval__(main) { return eval(main) } + const fsm = compile(composition) -function main(params) { const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) // encode error object @@ -316,8 +312,7 @@ function main(params) { const badRequest = error => Promise.reject({ code: 400, error }) const internalError = error => Promise.reject(encodeError(error)) - // catch all - return Promise.resolve().then(() => invoke(params)).catch(internalError) + return params => Promise.resolve().then(() => invoke(params)).catch(internalError) // do invocation function invoke(params) { @@ -381,7 +376,7 @@ function main(params) { // process one state console.log(`Entering ${state}`) - const json = __composition__[state] // json definition for current state + const json = fsm[state] // json definition for current state const current = state state = typeof json.next === 'undefined' ? undefined : current + json.next // default next state console.log(json) @@ -393,7 +388,7 @@ function main(params) { stack.unshift({ catch: current + json.catch }) break case 'let': - stack.unshift({ let: json.let }) + stack.unshift({ let: JSON.parse(JSON.stringify(json.let)) }) break case 'exit': if (stack.length === 0) return internalError(`State ${current} attempted to pop from an empty stack`) @@ -410,7 +405,7 @@ function main(params) { return { action: json.name, params, state: { $resume: { state, stack } } } // invoke continuation break case 'literal': - params = json.value + params = JSON.parse(JSON.stringify(json.value)) inspect() break case 'function': From fd47337374f12826c3f1108dc2c0a567609541d0 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 14:04:27 -0500 Subject: [PATCH 36/62] Remove dependency --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 6639e09..919ddca 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,7 @@ "openwhisk" ], "dependencies": { - "openwhisk": "^3.11.0", - "clone": "^2.1.1" + "openwhisk": "^3.11.0" }, "devDependencies": { "mocha": "^3.5.0" From 6ebf822182a2852a3b48c168a8cc239c8e09f603 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 14:26:12 -0500 Subject: [PATCH 37/62] Logging --- composer.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/composer.js b/composer.js index 24e2d5e..c85bc39 100644 --- a/composer.js +++ b/composer.js @@ -289,7 +289,7 @@ function conductor(composition) { var alternate = [{ type: 'pass', path }] if (!options.nosave) consequent = chain([{ type: 'pop', path }], consequent) if (!options.nosave) alternate = chain([{ type: 'pop', path }], alternate) - var fsm = chain(compile(json.test, path + ':test'), [{ type: 'choice', then: 1, else: consequent.length + 1, path }]) + var fsm = chain(compile(json.test, path + '.test'), [{ type: 'choice', then: 1, else: consequent.length + 1, path }]) if (!options.nosave) fsm = chain([{ type: 'push', path }], fsm) consequent.slice(-1)[0].next = 1 - fsm.length - consequent.length fsm.push(...consequent) @@ -374,12 +374,10 @@ function conductor(composition) { } // process one state - console.log(`Entering ${state}`) - const json = fsm[state] // json definition for current state + console.log(`Entering state ${state} at path fsm${json.path}`) const current = state state = typeof json.next === 'undefined' ? undefined : current + json.next // default next state - console.log(json) switch (json.type) { case 'choice': state = current + (params.value ? json.then : json.else) From 7f407a29da66278c98f40aaab97c6c602f89a6cf Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 17:45:06 -0500 Subject: [PATCH 38/62] dowhile --- composer.js | 20 ++++++++++++++++++++ test/test.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/composer.js b/composer.js index c85bc39..719ece6 100644 --- a/composer.js +++ b/composer.js @@ -128,6 +128,11 @@ class Composer { return new Composition({ type: 'while', test: this.task(test), body: this.task(body), options: validate(options) }) } + dowhile(body, test, options) { + if (arguments.length > 3) throw new ComposerError('Too many arguments') + return new Composition({ type: 'dowhile', test: this.task(test), body: this.task(body), options: validate(options) }) + } + try(body, handler, options) { if (arguments.length > 3) throw new ComposerError('Too many arguments') return new Composition({ type: 'try', body: this.task(body), handler: this.task(handler), options: validate(options) }) @@ -295,6 +300,21 @@ function conductor(composition) { fsm.push(...consequent) fsm.push(...alternate) return fsm + case 'dowhile': + var test = compile(json.test, path + '.test') + if (!options.nosave) test = chain([{ type: 'push', path }], test) + var fsm = [compile(json.body, path + '.body'), test, [{ type: 'choice', then: 1, else: 2, path }]].reduce(chain) + if (options.nosave) { + fsm.slice(-1)[0].then = 1 - fsm.length + fsm.slice(-1)[0].else = 1 + } else { + fsm.push({ type: 'pop', path }) + fsm.slice(-1)[0].next = 1 - fsm.length + } + var alternate = [{ type: 'pass', path }] + if (!options.nosave) alternate = chain([{ type: 'pop', path }], alternate) + fsm.push(...alternate) + return fsm } } diff --git a/test/test.js b/test/test.js index 0edc5a2..8ab364c 100644 --- a/test/test.js +++ b/test/test.js @@ -314,6 +314,41 @@ describe('composer', function () { }) }) + describe('dowhile', function () { + it('a few iterations', function () { + return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne'), { n: 4 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) + }) + + it('one iteration', function () { + return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), () => false), { n: 1 }) + .then(activation => assert.deepEqual(activation.response.result, { n: 0 })) + }) + + it('nosave option', function () { + return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), ({ n }) => ({ n, value: n !== 1 }), { nosave: true }), { n: 4 }) + .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) + }) + + it('invalid options', function () { + try { + invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid options')) + } + }) + + it('too many arguments', function () { + try { + invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne', {}, ({ n }) => ({ n: n - 1 })), { n: 4 }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + describe('try', function () { it('no error', function () { return invoke(composer.try(() => true, error => ({ message: error.error }))) From d8237414890f7ad070ace0900d891d707e83da57 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 18:11:43 -0500 Subject: [PATCH 39/62] Use dowhile for retry implementation --- composer.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.js b/composer.js index 719ece6..d0a5ebb 100644 --- a/composer.js +++ b/composer.js @@ -224,11 +224,11 @@ class Composer { if (typeof count !== 'number') throw new ComposerError('Invalid argument', count) const attempt = this.retain(this.seq(...Array.prototype.slice.call(arguments, 1)), { catch: true }) return this.let({ count }, - attempt, - this.while( - this.function(({ result }) => typeof result.error !== 'undefined' && count-- > 0, { helper: 'retry_1' }), - this.finally(this.function(({ params }) => params, { helper: 'retry_2' }), attempt)), - this.function(({ result }) => result, { helper: 'retry_3' })) + this.function(params => ({ params }), { helper: 'retry_1' }), + this.dowhile( + this.finally(this.function(({ params }) => params, { helper: 'retry_2' }), attempt), + this.function(({ result }) => typeof result.error !== 'undefined' && count-- > 0, { helper: 'retry_3' })), + this.function(({ result }) => result, { helper: 'retry_4' })) } } From 98ae04c9a3a8dcb72fbb768d13c546da683118e3 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Sat, 3 Feb 2018 19:59:53 -0500 Subject: [PATCH 40/62] Uglify --- composer.js | 20 ++++++++++++++------ package.json | 3 ++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/composer.js b/composer.js index d0a5ebb..45a09a4 100644 --- a/composer.js +++ b/composer.js @@ -23,6 +23,7 @@ const os = require('os') const path = require('path') const util = require('util') const openwhisk = require('openwhisk') +const uglify = require('uglify-es') class ComposerError extends Error { constructor(message, argument) { @@ -40,6 +41,12 @@ function validate(options) { let wsk +function encode({ name, action }) { + if (action.exec.kind !== 'composition') return { name, action } + const code = `${conductor}(${JSON.stringify(action.exec.composition)})\n` + return { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: action.exec.composition }] } } +} + class Composition { constructor(composition, actions = []) { Object.keys(composition).forEach(key => { @@ -65,8 +72,7 @@ class Composition { if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) const actions = [] if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) - const code = `const __eval__ = main => eval(main)\nconst main = (${conductor})(${JSON.stringify(this.composition, null, 4)})\n` - actions.push(...this.actions || [], { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: this.composition }] } }) + actions.push(...this.actions || [], { name, action: { exec: { kind: 'composition', composition: this.composition } } }) return new Composition({ type: 'action', name }, actions) } @@ -75,7 +81,7 @@ class Composition { if (this.composition.type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') let i = 0 return this.actions.reduce((promise, action) => - promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(action).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) + promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(encode(action)).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) } } @@ -161,7 +167,7 @@ class Composer { if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', exec) } if (typeof exec === 'string') { - exec = { kind: 'nodejs:default', exec } + exec = { kind: 'nodejs:default', code: exec } } if (typeof exec !== 'object' || exec === null) throw new ComposerError('Invalid argument', exec) return new Composition({ type: 'function', exec, options: validate(options) }) @@ -236,7 +242,9 @@ module.exports = options => new Composer(options) // conductor action -function conductor(composition) { +const conductor = `const __eval__ = main => eval(main)\nconst main = (${uglify.minify(`${init}`).code})` + +function init(composition) { function chain(front, back) { front.slice(-1)[0].next = 1 front.push(...back) @@ -429,7 +437,7 @@ function conductor(composition) { case 'function': let result try { - result = run(json.exec.exec) + result = run(json.exec.code) } catch (error) { console.error(error) result = { error: `An exception was caught at state ${current} (see log for details)` } diff --git a/package.json b/package.json index 919ddca..081ad4a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "openwhisk" ], "dependencies": { - "openwhisk": "^3.11.0" + "openwhisk": "^3.11.0", + "uglify-es": "^3.3.9" }, "devDependencies": { "mocha": "^3.5.0" From a4f0e913bf4eaa7d9fa37a508325fc090279a2f7 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 5 Feb 2018 11:51:53 -0500 Subject: [PATCH 41/62] Compositions are always arrays --- composer.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/composer.js b/composer.js index 45a09a4..9fd4942 100644 --- a/composer.js +++ b/composer.js @@ -61,10 +61,9 @@ class Composition { if (Array.isArray(component)) composition.push(...component); else composition.push(component) return composition }, []) - if (composition.length === 1) composition = composition[0] } if (actions.length > 0) this.actions = actions - this.composition = composition + this.composition = Array.isArray(composition) ? composition : [composition] } named(name) { @@ -78,7 +77,7 @@ class Composition { deploy() { if (arguments.length > 0) throw new ComposerError('Too many arguments') - if (this.composition.type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') + if (this.composition.length !== 1 || this.composition[0].type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') let i = 0 return this.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(encode(action)).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) From 5c1a3c38e73bedb1b5bc52ec8e9a03fe36d582c2 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 19 Feb 2018 14:18:05 -0500 Subject: [PATCH 42/62] Add compose binary --- bin/compose | 10 ++++++++++ package.json | 3 +++ 2 files changed, 13 insertions(+) create mode 100755 bin/compose diff --git a/bin/compose b/bin/compose new file mode 100755 index 0000000..4cd3311 --- /dev/null +++ b/bin/compose @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +const fs = require('fs') +const composer = require('../composer')() + +if (process.argv.length !== 4) { + console.error('Usage: compose ') +} else { + eval(fs.readFileSync(process.argv[3], { encoding: 'utf8' })).named(process.argv[2]).deploy() +} diff --git a/package.json b/package.json index 081ad4a..5bee527 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "scripts": { "test": "mocha" }, + "bin": { + "compose": "./bin/compose" + }, "repository": { "type": "git", "url": "https://github.com/ibm-functions/composer.git" From 0267518f35558957c5cc81afec6f45763841afde Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 19 Feb 2018 16:58:18 -0800 Subject: [PATCH 43/62] Add Composition.encode method --- composer.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/composer.js b/composer.js index 9fd4942..b5be971 100644 --- a/composer.js +++ b/composer.js @@ -75,6 +75,12 @@ class Composition { return new Composition({ type: 'action', name }, actions) } + encode() { + if (arguments.length > 0) throw new ComposerError('Too many arguments') + this.actions = this.actions.map(encode) + return this + } + deploy() { if (arguments.length > 0) throw new ComposerError('Too many arguments') if (this.composition.length !== 1 || this.composition[0].type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') From a946abe932cfb4a99b0a936e507749df957f5ca9 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 19 Feb 2018 17:46:27 -0800 Subject: [PATCH 44/62] Non-destructive encode --- composer.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.js b/composer.js index b5be971..a8d69a3 100644 --- a/composer.js +++ b/composer.js @@ -77,8 +77,7 @@ class Composition { encode() { if (arguments.length > 0) throw new ComposerError('Too many arguments') - this.actions = this.actions.map(encode) - return this + return new Composition(this.composition, this.actions.map(encode)) } deploy() { From 3441a68f6c359aa8fac2354bbf45b8ba8cf38bcc Mon Sep 17 00:00:00 2001 From: rodric rabbah Date: Wed, 21 Feb 2018 08:59:24 -0800 Subject: [PATCH 45/62] Fully qualify an action name. (#1) Detect malformed action names. Handle default namespace. --- composer.js | 29 ++++++++++++++++++++++++++++- test/test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/composer.js b/composer.js index a8d69a3..b98a8c0 100644 --- a/composer.js +++ b/composer.js @@ -47,6 +47,33 @@ function encode({ name, action }) { return { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: action.exec.composition }] } } } +/** + * Parses a (possibly fully qualified) resource name and validates it. If it's not a fully qualified name, + * then attempts to qualify it. + * + * Examples string to namespace, [package/]action name + * foo => /_/foo + * pkg/foo => /_/pkg/foo + * /ns/foo => /ns/foo + * /ns/pkg/foo => /ns/pkg/foo + */ +function parseActionName(name) { + if (typeof name !== 'string' || name.trim().length == 0) throw new ComposerError('Name is not specified') + name = name.trim() + let delimiter = '/' + let parts = name.split(delimiter) + let n = parts.length + let leadingSlash = name[0] == delimiter + // no more than /ns/p/a + if (n < 1 || n > 4 || (leadingSlash && n == 2) || (!leadingSlash && n == 4)) throw new ComposerError('Name is not valid') + // skip leading slash, all parts must be non empty (could tighten this check to match EntityName regex) + parts.forEach(function (part, i) { if (i > 0 && part.trim().length == 0) throw new ComposerError('Name is not valid') }) + let newName = parts.join(delimiter) + if (leadingSlash) return newName + else if (n < 3) return `${delimiter}_${delimiter}${newName}` + else return `${delimiter}${newName}` +} + class Composition { constructor(composition, actions = []) { Object.keys(composition).forEach(key => { @@ -179,7 +206,7 @@ class Composer { action(name, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) + name = parseActionName(name) // throws ComposerError if name is not valid let exec if (options && Array.isArray(options.sequence)) { // native sequence const components = options.sequence.map(a => a.indexOf('/') == -1 ? `/_/${a}` : a) diff --git a/test/test.js b/test/test.js index 8ab364c..f7ed6e6 100644 --- a/test/test.js +++ b/test/test.js @@ -26,6 +26,39 @@ describe('composer', function () { return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) }) + it('action name must parse to fully qualified', function() { + let combos = [ + { n: '', s: false, e: 'Name is not specified' }, + { n: ' ', s: false, e: 'Name is not specified' }, + { n: '/', s: false, e: 'Name is not valid' }, + { n: '//', s: false, e: 'Name is not valid' }, + { n: '/a', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, + { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/ /b', s: false, e: 'Name is not valid' }, + { n: 'a', e: false, s: '/_/a' }, + { n: 'a/b', e: false, s: '/_/a/b' }, + { n: 'a/b/c', e: false, s: '/a/b/c' }, + { n: '/a/b', e: false, s: '/a/b' }, + { n: '/a/b/c', e: false, s: '/a/b/c' } + ] + combos.forEach(({n, s, e}) => { + if (s) { + // good cases + assert.ok(composer.action(n).composition[0].name, s) + } else { + // error cases + try { + composer.action(n) + assert.fail() + } catch (error) { + assert.ok(error.message == e) + } + } + }) + }) + it('invalid options', function () { try { invoke(composer.function('foo', 'bar')) From d29ea8102350a5903e87148ca2d295b0e209eedf Mon Sep 17 00:00:00 2001 From: Nick Mitchell Date: Thu, 22 Feb 2018 12:08:35 -0500 Subject: [PATCH 46/62] add Composer.deserialize and add support for INSECURE_SSL in wskprops (#2) make initialization of wsk optional --- composer.js | 47 +++++++++++++++++++++++++++++------------------ test/test.js | 16 ++++++++++++++++ 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/composer.js b/composer.js index b98a8c0..2d2615a 100644 --- a/composer.js +++ b/composer.js @@ -118,30 +118,41 @@ class Composition { class Composer { constructor(options = {}) { - // try to extract apihost and key from wskprops - let apihost - let api_key - - try { - const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') - const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') - - for (let line of lines) { - let parts = line.trim().split('=') - if (parts.length === 2) { - if (parts[0] === 'APIHOST') { - apihost = parts[1] - } else if (parts[0] === 'AUTH') { - api_key = parts[1] + if (!options.no_wsk) { + // try to extract apihost and key from wskprops + let apihost + let api_key + let ignore_certs + + try { + const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') + const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') + + for (let line of lines) { + let parts = line.trim().split('=') + if (parts.length === 2) { + if (parts[0] === 'APIHOST') { + apihost = parts[1] + } else if (parts[0] === 'AUTH') { + api_key = parts[1] + } else if (parts[0] === 'INSECURE_SSL') { + ignore_certs = parts[1] === 'true' + } } } - } - } catch (error) { } + } catch (error) { } + + this.wsk = wsk = openwhisk(Object.assign({ apihost, api_key, ignore_certs }, options)) + } - this.wsk = wsk = openwhisk(Object.assign({ apihost, api_key }, options)) this.seq = this.sequence } + /** Take a serialized Composition and returns a Composition instance */ + deserialize({composition, actions}) { + return new Composition(composition, actions) + } + task(obj) { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (obj == null) return this.seq() diff --git a/test/test.js b/test/test.js index f7ed6e6..34b7341 100644 --- a/test/test.js +++ b/test/test.js @@ -10,6 +10,7 @@ describe('composer', function () { before('deploy test actions', function () { return Promise.all([ + composer.action('echo', { action: 'const main = x=>x' }).deploy(), composer.action('DivideByTwo', { action: 'function main({n}) { return { n: n / 2 } }' }).deploy(), composer.action('TripleAndIncrement', { action: 'function main({n}) { return { n: n * 3 + 1 } }' }).deploy(), composer.action('isNotOne', { action: 'function main({n}) { return { value: n != 1 } }' }).deploy(), @@ -177,6 +178,21 @@ describe('composer', function () { }) }) + describe('deserialize', function () { + it('should deserialize a serialized composition', function () { + const json = { + "composition": [{ + "type": "action", + "name": "echo" + }, { + "type": "action", + "name": "echo" + }] + } + return invoke(composer.deserialize(json), { message: 'hi' }).then(activation => assert.deepEqual(activation.response.result, { message: 'hi' })) + }) + }) + describe('tasks', function () { describe('action tasks', function () { it('action must return true', function () { From ec0ffda7b2f3aa29b12eddd911fea3b456130eec Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 22 Feb 2018 09:53:09 -0800 Subject: [PATCH 47/62] Tweaks --- composer.js | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/composer.js b/composer.js index 2d2615a..c9e1336 100644 --- a/composer.js +++ b/composer.js @@ -22,7 +22,6 @@ const fs = require('fs') const os = require('os') const path = require('path') const util = require('util') -const openwhisk = require('openwhisk') const uglify = require('uglify-es') class ComposerError extends Error { @@ -31,6 +30,9 @@ class ComposerError extends Error { } } +/** + * Validates options and converts to JSON + */ function validate(options) { if (typeof options === 'undefined') return if (typeof options !== 'object' || Array.isArray(options) || options === null) throw new ComposerError('Invalid options', options) @@ -39,11 +41,17 @@ function validate(options) { return JSON.parse(options) } +/** + * OpenWhisk client instance + */ let wsk +/** + * Encodes a composition as an action by injecting conductor code + */ function encode({ name, action }) { if (action.exec.kind !== 'composition') return { name, action } - const code = `${conductor}(${JSON.stringify(action.exec.composition)})\n` + const code = `${conductor}(${JSON.stringify(action.exec.composition)})\n` // invoke conductor on composition return { name, action: { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: action.exec.composition }] } } } @@ -67,7 +75,7 @@ function parseActionName(name) { // no more than /ns/p/a if (n < 1 || n > 4 || (leadingSlash && n == 2) || (!leadingSlash && n == 4)) throw new ComposerError('Name is not valid') // skip leading slash, all parts must be non empty (could tighten this check to match EntityName regex) - parts.forEach(function (part, i) { if (i > 0 && part.trim().length == 0) throw new ComposerError('Name is not valid') }) + parts.forEach(function (part, i) { if (i > 0 && part.trim().length == 0) throw new ComposerError('Name is not valid') }) let newName = parts.join(delimiter) if (leadingSlash) return newName else if (n < 3) return `${delimiter}_${delimiter}${newName}` @@ -76,6 +84,7 @@ function parseActionName(name) { class Composition { constructor(composition, actions = []) { + // collect actions defined in nested composition Object.keys(composition).forEach(key => { if (composition[key] instanceof Composition) { // TODO: check for duplicate entries @@ -83,22 +92,16 @@ class Composition { composition[key] = composition[key].composition } }) - if (Array.isArray(composition)) { - composition = composition.reduce((composition, component) => { - if (Array.isArray(component)) composition.push(...component); else composition.push(component) - return composition - }, []) - } if (actions.length > 0) this.actions = actions - this.composition = Array.isArray(composition) ? composition : [composition] + // flatten composition array + this.composition = Array.isArray(composition) ? [].concat(...composition) : [composition] } named(name) { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) - const actions = [] if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) - actions.push(...this.actions || [], { name, action: { exec: { kind: 'composition', composition: this.composition } } }) + const actions = (this.actions || []).concat({ name, action: { exec: { kind: 'composition', composition: this.composition } } }) return new Composition({ type: 'action', name }, actions) } @@ -111,8 +114,7 @@ class Composition { if (arguments.length > 0) throw new ComposerError('Too many arguments') if (this.composition.length !== 1 || this.composition[0].type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') let i = 0 - return this.actions.reduce((promise, action) => - promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(encode(action)).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) + return this.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(encode(action)).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) } } @@ -142,14 +144,14 @@ class Composer { } } catch (error) { } - this.wsk = wsk = openwhisk(Object.assign({ apihost, api_key, ignore_certs }, options)) + this.wsk = wsk = require('openwhisk')(Object.assign({ apihost, api_key, ignore_certs }, options)) } this.seq = this.sequence } /** Take a serialized Composition and returns a Composition instance */ - deserialize({composition, actions}) { + deserialize({ composition, actions }) { return new Composition(composition, actions) } From f206b700196f1ff3479546504220fc4bd5ebe602 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 22 Feb 2018 10:43:07 -0800 Subject: [PATCH 48/62] Avoid options field with undefined value --- composer.js | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/composer.js b/composer.js index c9e1336..126d29a 100644 --- a/composer.js +++ b/composer.js @@ -34,8 +34,8 @@ class ComposerError extends Error { * Validates options and converts to JSON */ function validate(options) { - if (typeof options === 'undefined') return - if (typeof options !== 'object' || Array.isArray(options) || options === null) throw new ComposerError('Invalid options', options) + if (options == null) return + if (typeof options !== 'object' || Array.isArray(options)) throw new ComposerError('Invalid options', options) options = JSON.stringify(options) if (options === '{}') return return JSON.parse(options) @@ -83,7 +83,7 @@ function parseActionName(name) { } class Composition { - constructor(composition, actions = []) { + constructor(composition, options, actions = []) { // collect actions defined in nested composition Object.keys(composition).forEach(key => { if (composition[key] instanceof Composition) { @@ -93,6 +93,8 @@ class Composition { } }) if (actions.length > 0) this.actions = actions + options = validate(options) + if (typeof options !== 'undefined') composition = Object.assign({ options }, composition) // flatten composition array this.composition = Array.isArray(composition) ? [].concat(...composition) : [composition] } @@ -102,12 +104,12 @@ class Composition { if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) const actions = (this.actions || []).concat({ name, action: { exec: { kind: 'composition', composition: this.composition } } }) - return new Composition({ type: 'action', name }, actions) + return new Composition({ type: 'action', name }, null, actions) } encode() { if (arguments.length > 0) throw new ComposerError('Too many arguments') - return new Composition(this.composition, this.actions.map(encode)) + return new Composition(this.composition, null, this.actions.map(encode)) } deploy() { @@ -170,27 +172,27 @@ class Composer { if(test, consequent, alternate, options) { if (arguments.length > 4) throw new ComposerError('Too many arguments') - return new Composition({ type: 'if', test: this.task(test), consequent: this.task(consequent), alternate: this.task(alternate), options: validate(options) }) + return new Composition({ type: 'if', test: this.task(test), consequent: this.task(consequent), alternate: this.task(alternate) }, options) } while(test, body, options) { if (arguments.length > 3) throw new ComposerError('Too many arguments') - return new Composition({ type: 'while', test: this.task(test), body: this.task(body), options: validate(options) }) + return new Composition({ type: 'while', test: this.task(test), body: this.task(body) }, options) } dowhile(body, test, options) { if (arguments.length > 3) throw new ComposerError('Too many arguments') - return new Composition({ type: 'dowhile', test: this.task(test), body: this.task(body), options: validate(options) }) + return new Composition({ type: 'dowhile', test: this.task(test), body: this.task(body) }, options) } try(body, handler, options) { if (arguments.length > 3) throw new ComposerError('Too many arguments') - return new Composition({ type: 'try', body: this.task(body), handler: this.task(handler), options: validate(options) }) + return new Composition({ type: 'try', body: this.task(body), handler: this.task(handler) }, options) } finally(body, finalizer, options) { if (arguments.length > 3) throw new ComposerError('Too many arguments') - return new Composition({ type: 'finally', body: this.task(body), finalizer: this.task(finalizer), options: validate(options) }) + return new Composition({ type: 'finally', body: this.task(body), finalizer: this.task(finalizer) }, options) } let(declarations) { // varargs, no options @@ -201,7 +203,7 @@ class Composer { literal(value, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') if (typeof value === 'function') throw new ComposerError('Invalid argument', value) - return new Composition({ type: 'literal', value: typeof value === 'undefined' ? {} : value, options: validate(options) }) + return new Composition({ type: 'literal', value: typeof value === 'undefined' ? {} : value }, options) } function(exec, options) { @@ -214,7 +216,7 @@ class Composer { exec = { kind: 'nodejs:default', code: exec } } if (typeof exec !== 'object' || exec === null) throw new ComposerError('Invalid argument', exec) - return new Composition({ type: 'function', exec, options: validate(options) }) + return new Composition({ type: 'function', exec }, options) } action(name, options) { @@ -241,7 +243,7 @@ class Composer { exec = options.action delete options.action } - return new Composition({ type: 'action', name, options: validate(options) }, exec ? [{ name, action: { exec } }] : []) + return new Composition({ type: 'action', name }, options, exec ? [{ name, action: { exec } }] : []) } retain(body, options) { @@ -262,7 +264,7 @@ class Composer { } if (options && typeof options.field !== 'undefined' && typeof options.field !== 'string') throw new ComposerError('Invalid options', options) // return new Composition({ params, result: body(params) } if no error, otherwise body(params) - return new Composition({ type: 'retain', body: this.task(body), options: validate(options) }) + return new Composition({ type: 'retain', body: this.task(body) }, options) } repeat(count) { // varargs, no options From 82857d5934a6e969984ff564512e106455d2af05 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 22 Feb 2018 10:48:40 -0800 Subject: [PATCH 49/62] Bug fix --- composer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.js b/composer.js index 126d29a..21b8fc1 100644 --- a/composer.js +++ b/composer.js @@ -152,9 +152,9 @@ class Composer { this.seq = this.sequence } - /** Take a serialized Composition and returns a Composition instance */ + /** Takes a serialized Composition and returns a Composition instance */ deserialize({ composition, actions }) { - return new Composition(composition, actions) + return new Composition(composition, null, actions) } task(obj) { From f6c9cdc22eb7d0beb936fef4726e3be60958af0e Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 22 Feb 2018 11:21:54 -0800 Subject: [PATCH 50/62] Add optional name argument to composition.encode and deploy --- bin/compose | 3 +-- composer.js | 25 +++++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bin/compose b/bin/compose index 4cd3311..4039161 100755 --- a/bin/compose +++ b/bin/compose @@ -1,10 +1,9 @@ #!/usr/bin/env node -const fs = require('fs') const composer = require('../composer')() if (process.argv.length !== 4) { console.error('Usage: compose ') } else { - eval(fs.readFileSync(process.argv[3], { encoding: 'utf8' })).named(process.argv[2]).deploy() + eval(require('fs').readFileSync(process.argv[3], { encoding: 'utf8' })).deploy(process.argv[2]) } diff --git a/composer.js b/composer.js index 21b8fc1..5a0017b 100644 --- a/composer.js +++ b/composer.js @@ -47,7 +47,7 @@ function validate(options) { let wsk /** - * Encodes a composition as an action by injecting conductor code + * Encodes a composition as an action by injecting the conductor code */ function encode({ name, action }) { if (action.exec.kind !== 'composition') return { name, action } @@ -99,6 +99,7 @@ class Composition { this.composition = Array.isArray(composition) ? [].concat(...composition) : [composition] } + /** Names the composition and returns a composition which invokes the named composition */ named(name) { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) @@ -107,16 +108,24 @@ class Composition { return new Composition({ type: 'action', name }, null, actions) } - encode() { - if (arguments.length > 0) throw new ComposerError('Too many arguments') - return new Composition(this.composition, null, this.actions.map(encode)) + /** Encodes all compositions as actions by injecting the conductor code in them */ + encode(name) { + if (arguments.length > 1) throw new ComposerError('Too many arguments') + if (typeof name !== 'undefined' && typeof name !== 'string') throw new ComposerError('Invalid argument', name) + const obj = typeof name === 'string' ? this.named(name) : this + if (obj.composition.length !== 1 || obj.composition[0].type !== 'action') throw new ComposerError('Cannot encode anonymous composition') + return new Composition(obj.composition, null, obj.actions.map(encode)) } - deploy() { - if (arguments.length > 0) throw new ComposerError('Too many arguments') - if (this.composition.length !== 1 || this.composition[0].type !== 'action') throw new ComposerError('Cannot deploy anonymous composition') + /** Encodes all compositions as actions and deploys all actions */ + deploy(name) { + if (arguments.length > 1) throw new ComposerError('Too many arguments') + const obj = this.encode(name) + if (typeof wsk === 'undefined') return obj // no openwhisk client instance, stop let i = 0 - return this.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action)).catch(() => { }).then(() => wsk.actions.update(encode(action)).then(() => i++, err => console.error(err))), Promise.resolve()).then(() => i) + return obj.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action)).catch(() => { }) + .then(() => wsk.actions.update(action).then(() => i++, err => console.error(err))), Promise.resolve()) + .then(() => i) } } From d42567a8bfc87f468058534b4b5e2c599c4cc493 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Thu, 22 Feb 2018 11:29:16 -0800 Subject: [PATCH 51/62] Test tweak --- test/test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test.js b/test/test.js index 34b7341..c792b98 100644 --- a/test/test.js +++ b/test/test.js @@ -3,7 +3,7 @@ const composer = require('../composer')() const name = 'TestAction' // compile, deploy, and blocking invoke -const invoke = (task, params = {}, blocking = true) => task.named(name).deploy().then(() => composer.wsk.actions.invoke({ name, params, blocking })) +const invoke = (task, params = {}, blocking = true) => task.deploy(name).then(() => composer.wsk.actions.invoke({ name, params, blocking })) describe('composer', function () { this.timeout(20000) From beb3c7ffa1656086aa0df0f03815b0d127b24adf Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 26 Feb 2018 14:20:33 -0500 Subject: [PATCH 52/62] Parse composition name --- composer.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.js b/composer.js index 5a0017b..b2ffb17 100644 --- a/composer.js +++ b/composer.js @@ -103,6 +103,7 @@ class Composition { named(name) { if (arguments.length > 1) throw new ComposerError('Too many arguments') if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) + name = parseActionName(name) if (this.actions && this.actions.findIndex(action => action.name === name) !== -1) throw new ComposerError('Duplicate action name', name) const actions = (this.actions || []).concat({ name, action: { exec: { kind: 'composition', composition: this.composition } } }) return new Composition({ type: 'action', name }, null, actions) @@ -121,11 +122,10 @@ class Composition { deploy(name) { if (arguments.length > 1) throw new ComposerError('Too many arguments') const obj = this.encode(name) - if (typeof wsk === 'undefined') return obj // no openwhisk client instance, stop - let i = 0 - return obj.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action)).catch(() => { }) - .then(() => wsk.actions.update(action).then(() => i++, err => console.error(err))), Promise.resolve()) - .then(() => i) + if (typeof wsk === 'undefined') return Promise.resolve(obj) // no openwhisk client instance, stop + return obj.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action).catch(() => { })) + .then(() => wsk.actions.update(action)), Promise.resolve()) + .then(() => obj) } } From 0596da5b71504add7e79fa077a5325ca8879ec26 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 26 Feb 2018 14:22:20 -0500 Subject: [PATCH 53/62] Improve compose command --- bin/compose | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/bin/compose b/bin/compose index 4039161..0f99fc0 100755 --- a/bin/compose +++ b/bin/compose @@ -1,9 +1,27 @@ #!/usr/bin/env node -const composer = require('../composer')() +'use strict' -if (process.argv.length !== 4) { - console.error('Usage: compose ') +const fs = require('fs') +const vm = require('vm') +const minimist = require('minimist') + +const argv = minimist(process.argv.slice(2), { string: ['apihost', 'auth', 'deploy'], boolean: 'insecure', alias: { auth: 'u', insecure: 'i' } }) + +if (argv._.length !== 1) { + console.error('Usage: compose [--deploy ] [--apihost ] [--auth ] [--insecure]') + return +} + +const filename = argv._[0] +const source = fs.readFileSync(filename, { encoding: 'utf8' }) +const options = { ignore_certs: argv.insecure } +if (argv.apihost) options.apihost = argv.apihost +if (argv.auth) options.api_key = argv.auth +const composer = require('../composer')(options) +const composition = filename.slice(filename.lastIndexOf('.')) === '.js' ? vm.runInNewContext(source, { composer, require, console, process }) : composer.deserialize(JSON.parse(source)) +if (argv.deploy) { + composition.deploy(argv.deploy).catch(console.error) } else { - eval(require('fs').readFileSync(process.argv[3], { encoding: 'utf8' })).deploy(process.argv[2]) + console.log(JSON.stringify(composition, null, 4)) } From 6c59de4631b0bfa8562028330ad6e72d7a0d8fd4 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 26 Feb 2018 23:48:49 -0500 Subject: [PATCH 54/62] Tweaks and doc --- README.md | 2 +- bin/compose | 10 +- composer.js | 100 ++++++++-------- docs/COMPOSER.md | 285 +++++++++++++++++++++++++++++----------------- package.json | 1 + samples/demo.js | 10 +- samples/demo.json | 54 ++++----- test/test.js | 77 +++++++------ 8 files changed, 304 insertions(+), 235 deletions(-) diff --git a/README.md b/README.md index 7febfc9..d80d3d8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,6 @@ you to [join us on slack](http://ibm.biz/composer-users). This repository includes: * a [composer](composer.js) node.js module to author compositions using JavaScript, - * a [tutorial](docs) for getting started with composer in the [docs](docs) folder, + * a [tutorial](docs/README.md) for getting started with composer in the [docs](docs) folder, * example compositions in the [samples](samples) folder, * tests in the [test](test) folder. diff --git a/bin/compose b/bin/compose index 0f99fc0..bfe723c 100755 --- a/bin/compose +++ b/bin/compose @@ -5,6 +5,7 @@ const fs = require('fs') const vm = require('vm') const minimist = require('minimist') +const composer = require('../composer') const argv = minimist(process.argv.slice(2), { string: ['apihost', 'auth', 'deploy'], boolean: 'insecure', alias: { auth: 'u', insecure: 'i' } }) @@ -15,13 +16,12 @@ if (argv._.length !== 1) { const filename = argv._[0] const source = fs.readFileSync(filename, { encoding: 'utf8' }) -const options = { ignore_certs: argv.insecure } -if (argv.apihost) options.apihost = argv.apihost -if (argv.auth) options.api_key = argv.auth -const composer = require('../composer')(options) const composition = filename.slice(filename.lastIndexOf('.')) === '.js' ? vm.runInNewContext(source, { composer, require, console, process }) : composer.deserialize(JSON.parse(source)) if (argv.deploy) { - composition.deploy(argv.deploy).catch(console.error) + const options = { ignore_certs: argv.insecure } + if (argv.apihost) options.apihost = argv.apihost + if (argv.auth) options.api_key = argv.auth + composer.openwhisk(options).compositions.deploy(composition,argv.deploy).catch(console.error) } else { console.log(JSON.stringify(composition, null, 4)) } diff --git a/composer.js b/composer.js index b2ffb17..d908b22 100644 --- a/composer.js +++ b/composer.js @@ -41,11 +41,6 @@ function validate(options) { return JSON.parse(options) } -/** - * OpenWhisk client instance - */ -let wsk - /** * Encodes a composition as an action by injecting the conductor code */ @@ -117,48 +112,59 @@ class Composition { if (obj.composition.length !== 1 || obj.composition[0].type !== 'action') throw new ComposerError('Cannot encode anonymous composition') return new Composition(obj.composition, null, obj.actions.map(encode)) } +} + +class Compositions { + constructor(wsk) { + this.actions = wsk.actions + } - /** Encodes all compositions as actions and deploys all actions */ - deploy(name) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - const obj = this.encode(name) - if (typeof wsk === 'undefined') return Promise.resolve(obj) // no openwhisk client instance, stop - return obj.actions.reduce((promise, action) => promise.then(() => wsk.actions.delete(action).catch(() => { })) - .then(() => wsk.actions.update(action)), Promise.resolve()) - .then(() => obj) + deploy(composition, name) { + if (arguments.length > 2) throw new ComposerError('Too many arguments') + if (!(composition instanceof Composition)) throw new ComposerError('Invalid argument', composition) + const obj = composition.encode(name) + return obj.actions.reduce((promise, action) => promise.then(() => this.actions.delete(action).catch(() => { })) + .then(() => this.actions.update(action)), Promise.resolve()) + .then(() => composition) } } class Composer { - constructor(options = {}) { - if (!options.no_wsk) { - // try to extract apihost and key from wskprops - let apihost - let api_key - let ignore_certs - - try { - const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') - const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') - - for (let line of lines) { - let parts = line.trim().split('=') - if (parts.length === 2) { - if (parts[0] === 'APIHOST') { - apihost = parts[1] - } else if (parts[0] === 'AUTH') { - api_key = parts[1] - } else if (parts[0] === 'INSECURE_SSL') { - ignore_certs = parts[1] === 'true' - } + openwhisk(options) { + // try to extract apihost and key from wskprops + let apihost + let api_key + let ignore_certs + + try { + const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') + const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') + + for (let line of lines) { + let parts = line.trim().split('=') + if (parts.length === 2) { + if (parts[0] === 'APIHOST') { + apihost = parts[1] + } else if (parts[0] === 'AUTH') { + api_key = parts[1] + } else if (parts[0] === 'INSECURE_SSL') { + ignore_certs = parts[1] === 'true' } } - } catch (error) { } + } + } catch (error) { } - this.wsk = wsk = require('openwhisk')(Object.assign({ apihost, api_key, ignore_certs }, options)) - } + const wsk = require('openwhisk')(Object.assign({ apihost, api_key, ignore_certs }, options)) + wsk.compositions = new Compositions(wsk) + return wsk + } + + seq() { + return this.sequence(...arguments) + } - this.seq = this.sequence + value() { + return this.literal(...arguments) } /** Takes a serialized Composition and returns a Composition instance */ @@ -215,17 +221,17 @@ class Composer { return new Composition({ type: 'literal', value: typeof value === 'undefined' ? {} : value }, options) } - function(exec, options) { + function(fun, options) { if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (typeof exec === 'function') { - exec = `${exec}` - if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', exec) + if (typeof fun === 'function') { + fun = `${fun}` + if (fun.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', fun) } - if (typeof exec === 'string') { - exec = { kind: 'nodejs:default', code: exec } + if (typeof fun === 'string') { + fun = { kind: 'nodejs:default', code: fun } } - if (typeof exec !== 'object' || exec === null) throw new ComposerError('Invalid argument', exec) - return new Composition({ type: 'function', exec }, options) + if (typeof fun !== 'object' || fun === null) throw new ComposerError('Invalid argument', fun) + return new Composition({ type: 'function', exec: fun }, options) } action(name, options) { @@ -293,7 +299,7 @@ class Composer { } } -module.exports = options => new Composer(options) +module.exports = new Composer() // conductor action diff --git a/docs/COMPOSER.md b/docs/COMPOSER.md index e951f82..b71ad70 100644 --- a/docs/COMPOSER.md +++ b/docs/COMPOSER.md @@ -1,185 +1,260 @@ -# JavaScript Composer API +# Composer -The [composer](../composer.js) module makes it possible to compose actions programmatically. The module is typically used as illustrated in [samples/demo.js](../samples/demo.js): +The [`composer`](../composer.js) Node.js module makes it possible define action [compositions](Compositions) using [combinators](Combinators). -```javascript -const composer = require('@ibm-functions/composer') - -// author action composition -const app = composer.if('authenticate', /* then */ 'welcome', /* else */ 'login') +## Installation -// compile action composition -composer.compile(app, 'demo.json') +To install the `composer` module use the Node Package Manager: +``` +npm -g install @ibm-functions/composer ``` +We recommend to install the module globally (with `-g` option) so the `compose` command is added to the path. Otherwise, it can be found in the `bin` folder of the module installation. -When using the programming shell `fsh` however, there is no need to instantiate the module or invoke compile explicitly. The `demo.js` file can be shortened to: +## Example +A composition is typically defined by means of a Javascript file as illustrated in [samples/demo.js](../samples/demo.js): ```javascript -composer.if('authenticate', /* then */ 'welcome', /* else */ 'login') +composer.if('authenticate', /* then */ 'success', /* else */ 'failure') ``` +This example composition composes three actions named `authenticate`, `success`, and `failure` using the `composer.if` combinator. -and used as follows: - -```bash -$ fsh app create demo demo.js +To deploy this composition use the `compose` command: ``` +compose demo.js --deploy demo +``` +This command creates an action named `demo` that implements the composition. -This example program composes actions `authenticate`, `welcome`, and `login` using the `composer.if` composition method. The `composer.compile` method produces a JSON object encoding the composition and optionally writes the object to a file. The compiled composition is shown in [samples/demo.json](../samples/demo.json). - -To deploy and run this composition and others, please refer to [README.md](README.md). - -## Compositions and Compiled Compositions - -All composition methods return a _composition_ object. Composition objects must be _compiled_ using the `composer.compile` method before export or invocation. Compiled compositions objects are JSON objects that obey the specification described in [FORMAT.md](FORMAT.md). They can be converted to and from strings using the `JSON.stringify` and `JSON.parse` methods. In contrast, the format of composition objects is not specified and may change in the future. +Assuming the composed actions are already deployed, this composition may be invoked like any action, for instance using the OpenWhisk CLI: +``` +wsk action invoke demo -r -p password passw0rd +``` +``` +{ + message: "Failure" +} +``` +An invocation of a composition creates a series of activation records: +``` +wsk action invoke demo -p password passw0rd +``` +``` +ok: invoked /_/demo with id 4f91f9ed0d874aaa91f9ed0d87baaa07 +``` +``` +wsk activation list +``` +``` +activations +fd89b99a90a1462a89b99a90a1d62a8e demo +eaec119273d94087ac119273d90087d0 failure +3624ad829d4044afa4ad829d40e4af60 demo +a1f58ade9b1e4c26b58ade9b1e4c2614 authenticate +3624ad829d4044afa4ad829d40e4af60 demo +4f91f9ed0d874aaa91f9ed0d87baaa07 demo +``` +The entry with the earliest start time (`4f91f9ed0d874aaa91f9ed0d87baaa07`) summarizes the invocation of the composition while other entries record later activations caused by the composition invocation. There is one entry for each invocation of a composed action (`a1f58ade9b1e4c26b58ade9b1e4c2614` and `eaec119273d94087ac119273d90087d0`). The remaining entries record the beginning and end of the composition as well as the transitions between the composed actions. -### composer.compile(_composition_[, _filename_]) +Compositions are implemented by means of OpenWhisk conductor actions. The documentation of [conductor actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md) discusses activation records in greater details. -`composer.compile(composition, filename)` compiles the composition object to its JSON representation and writes the JSON object to the file `filename` if specified. It returns the compiled composition object. +## JSON format -## Tasks +The `compose` command when not invoked with the `--deploy` option returns the composition encoded as a JSON dictionary: +``` +compose demo.js +``` +``` +{ + "composition": [ + { + "type": "if", + "test": [ + { + "type": "action", + "name": "/_/authenticate" + } + ], + "consequent": [ + { + "type": "action", + "name": "/_/success" + } + ], + "alternate": [ + { + "type": "action", + "name": "/_/failure" + } + ] + } + ] +} +``` +The JSON format is documented in [FORMAT.md](FORMAT.md). -Composition methods compose _tasks_. A task is one of the following: +A JSON-encoded composition may be deployed using the `compose` command: +``` +compose demo.js > demo.json +compose demo.json --deploy demo +``` -| Type | Description | Examples | -| --:| --- | --- | -| _function task_ | a JavaScript function expression | `params => params` or `function (params) { return params }` | -| _action task_ | an OpenWhisk action | `'/whisk.system/utils/echo'` | -| _composition_ | a composition | `composer.retry(3, 'connect')` | -| _compiled composition_ | a compiled composition | `composer.compile(composer.retry(3, 'connect'))` | +## Parameter Objects and Error Objects -Function expressions occurring in action compositions cannot capture any part of their declaration environment. They may however access and mutate variables in an environment consisting of the variables declared by the [composer.let](#composerletname-value-task_1-task_2-) composition method as discussed below. +A composition, like any action, accepts a JSON dictionary (the _input parameter object_) and produces a JSON dictionary (the _output parameter object_). An output parameter object with an `error` field is an _error object_. A composition _fails_ if it produces an error object. -Actions are specified by name. Fully qualified names and aliases are supported following the [usual conventions](https://github.com/apache/incubator-openwhisk/blob/master/docs/reference.md). +By convention, an error object returned by a composition is stripped from all fields except from the `error` field. This behavior is consistent with the OpenWhisk action semantics, e.g., the action with code `function main() { return { error: 'KO', message: 'OK' } }` outputs `{ error: 'KO' }`. -A function task applies the function to the [parameter object](#parameter-objects-and-error-objects). An action task the invokes the action with the given name on the parameter object. A composition task or compiled composition task applies the composition to the parameter object. +## Combinators -## Parameter Objects and Error Objects +The `composer` module offers a number of combinators to define compositions: -A task is a function that consumes a JSON dictionary (the _input parameter object_) and produces a JSON dictionary (the _output parameter object_). An output parameter object of a task with an `error` field is an _error object_. A task _fails_ if it produces an error object. +| Combinator | Description | Example | +| --:| --- | --- | +| [`action`](#composeractionname) | action | `composer.action('echo')` | +| [`function`](#composerfunctionfun) | function | `composer.function(({ x, y }) => ({ product: x * y }))` | +| [`literal` or `value`](#composerliteralvalue) | constant value | `composer.literal({ message: 'Hello, World!' })` | +| [`sequence` or `seq`](#composersequencecomposition1-composition2) | sequence | `composer.sequence('foo', 'bar')` | +| [`let`](#composerlet-name-value-composition1-composition2) | variable declarations | `composer.let({ count: 3, message: 'hello' }, ...)` | +| [`if`](#composerifcondition-consequent-alternate) | conditional | `composer.if('authenticate', 'success', 'failure')` | +| [`while`](#composerwhilecondition-composition) | loop | `composer.while('notEnough', 'doMore')` | +| [`dowhile`](#TODO) | loop at least once | `composer.dowhile('fetchData', 'needMoreData')` | +| [`repeat`](#composerrepeatcount-composition) | counted loop | `composer.repeat(3, 'hello')` | +| [`try`](#composertrycomposition-handler) | error handling | `composer.try('divideByN', 'NaN')` | +| [`finally`](#TODO) | finalization | `composer.finally('tryThis', 'doThatAlways')` | +| [`retry`](#composerretrycount-composition) | error recovery | `composer.retry(3, 'connect')` | +| [`retain`](#composerretaincomposition) | persistence | `composer.retain('validateInput')` | + +The `action`, `function`, and `literal` combinators and their synonymous construct compositions respectively from actions, functions, and constant values. The other combinators combine existing compositions to produce new compositions. + +Where a composition is expected, the following shorthands are permitted: + - `name` of type `string` stands for `composer.action(name)`, + - `fun` of type `function` stands for `composer.function(fun)`, + - `null` stands for the empty sequence `composer.sequence()`. + +### composer.action(_name_) + +`composer.action(name)` is a composition with a single action named _name_. It invokes the action named _name_ on the input parameter object for the composition and returns the output parameter object of this action invocation. + +The action _name_ may specify the namespace and/or package containing the action following the usual OpenWhisk grammar. If no namespace is specified, the default namespace is assumed. If no package is specified, the default package is assumed. + +Examples: +``` +composer.action('hello') +composer.action('myPackage/myAction') +composer.action('/whisk.system/utils/echo') +``` -Values returned by constant and function tasks are converted to JSON using `JSON.stringify` followed by `JSON.parse`. Values other than JSON dictionaries are replaced with a dictionary with a unique `value` field with the converted value. -For instance, the task `42` outputs the JSON dictionary `{ value: 42 }`. +### composer.function(_fun_) -By convention, an error object returned by a task is stripped from all fields except from the `error` field. For instance, the task `() => ({ error: 'KO', message: 'OK' })` outputs the JSON dictionary `{ error: 'KO' }`. This is to be consistent with the OpenWhisk action semantics, e.g., the action with code `function main() { return { error: 'KO', message: 'OK' } }` outputs `{ error: 'KO' }`. +`composer.function(fun)` is a composition with a single Javascript function _fun_. It applies the specified function to the input parameter object for the composition. -## Composition Methods -The following composition methods are currently supported: + - If the function returns a value of type `function`, the composition returns an error object `{ error }`. + - If the function throws an exception, the composition returns an error object `{ error }`. + - If the function returns a value of type other than function, the value is first converted to a JSON value using `JSON.stringify` followed by `JSON.parse`. If the resulting JSON value is not a JSON dictionary, the JSON value is then wrapped into a `{ value }` dictionary. The composition returns the final JSON dictionary. + - If the function does not return a value and does not throw an exception, the composition returns the input parameter object for the composition converted to a JSON dictionary using `JSON.stringify` followed by `JSON.parse`. -| Composition | Description | Example | -| --:| --- | --- | -| [`task`](#composertasktask-options) | single task | `composer.task('sayHi', { input: 'userInfo' })` | -| [`value`](#composervaluejson) | constant value | `composer.value({ message: 'Hello World!' })` | -| [`sequence`](#composersequencetask_1-task_2-) | sequence | `composer.sequence('getLocation', 'getWeatherForLocation')` | -| [`let`](#composerletname-value-task_1-task_2-) | variables | `composer.let('n', 42, ...)` | -| [`if`](#composerifcondition-consequent-alternate) | conditional | `composer.if('authenticate', /* then */ 'welcome', /* else */ 'login')` | -| [`while`](#composerwhilecondition-task) | loop | `composer.while('needMoreData', 'fetchMoreData')` | -| [`try`](#composertrytask-handler) | error handling | `composer.try('DivideByN', /* catch */ 'NaN')` | -| [`repeat`](#composerrepeatcount-task) | repetition | `composer.repeat(42, 'sayHi')` | -| [`retry`](#composerretrycount-task) | error recovery | `composer.retry(3, 'connect')` | -| [`retain`](#composerretaintask-flag) | parameter retention | `composer.retain('validateInput')` | +Examples: +``` +composer.function(params => ({ message: 'Hello ' + params.name })) +composer.function(function (params) { return { error: 'error' } }) -### composer.task(_task_[, _options_]) +function product({ x, y }) { return { product: x * y } } +composer.function(product) +``` -`composer.task(task, options)` is a composition with a single task _task_. The optional _options_ parameter may alter the task behavior as follows: +#### Environment capture - * If _options.merge_ evaluates to true the output parameter object for the composition is obtained by merging the output parameter object for the task into the input parameter object (unless the task produces an error object). +Functions intended for compositions cannot capture any part of their environment. They may however access and mutate variables in an environment consisting of the variables declared by the [composer.let](#composerletname-value-composition_1-composition_2-) combinator discussed below. - For instance, the composition `composer.task(42, { merge: true })` invoked on the input parameter object `{ value: 0, message: 'OK' }` outputs `{ value: 42, message: 'OK' }`. - - * Alternatively if _options.output_ is defined the output parameter object for the composition is obtained by assigning the output parameter object for the task to the _options.output_ field of the input parameter object for the composition. Additionally, if _options.input_ is defined the input parameter for the task is only the value of the field _options.input_ of the input parameter object for the composition as opposed to the full input parameter object. +The following is not legal: +``` +let name = 'Dave' +composer.function(params => ({ message: 'Hello ' + name })) +``` - For instance, the composition `composer.task(({n}) => ({ n: n+1 }), { input: 'in', output: 'out' })` invoked on the input parameter object `{ in: { n: 42 } }` outputs `{ in: { n: 42 }, out: { n: 43 } }`. - - If the value of the _options.input_ field is not a JSON dictionary is it replaced with a dictionary with a unique field `value` with the field's value. +The following is legal: +``` +composer.let({ name: 'Dave' }, composer.function(params => ({ message: 'Hello ' + name }))) +``` -### composer.value(_json_) +### composer.literal(_value_) -`composer.value(json)` outputs _json_ if it is a JSON dictionary. If _json_ is not a JSON dictionary is it replaced with a dictionary with a unique field `value` with value _json_. +`composer.literal(value)` outputs a constant JSON dictionary. This dictionary is obtained by first converting the _value_ argument to JSON using `JSON.stringify` followed by `JSON.parse`. If the resulting JSON value is not a JSON dictionary, the JSON value is then wrapped into a `{ value }` dictionary. -The _json_ value may be computed at composition time. For instance, the following composition captures the composition time: +The _value_ argument may be computed at composition time. For instance, the following composition captures the date of the composition: ```javascript -composer.value(Date()) +composer.literal(Date()) ``` -### composer.sequence(_task\_1_, _task\_2_, ...) +### composer.sequence(_composition\_1_, _composition\_2_...) -`composer.sequence(task_1, task_2, ...)` runs a sequence of tasks (possibly empty). +`composer.sequence(composition_1, composition_2, ...)` chains a series of compositions (possibly empty). -The input parameter object for the composition is the input parameter object of the first task in the sequence. The output parameter object of one task in the sequence is the input parameter object for the next task in the sequence. The output parameter object of the last task in the sequence is the output parameter object for the composition. +The input parameter object for the composition is the input parameter object of the first composition in the sequence. The output parameter object of one composition in the sequence is the input parameter object for the next composition in the sequence. The output parameter object of the last composition in the sequence is the output parameter object for the composition. -If one of the tasks fails, the remainder of the sequence is not executed. The output parameter object for the composition is the error object produced by the failed task. +If one of the compositions fails, the remainder of the sequence is not executed. The output parameter object for the composition is the error object produced by the failed composition. -An empty sequence behaves as a sequence with a single function task `params => params`. The output parameter object for the empty sequence is its input parameter object unless it is an error object, in which case, as usual, the error object only contains the `error` field of the input parameter object. +An empty sequence behaves as a sequence with a single function `params => params`. The output parameter object for the empty sequence is its input parameter object unless it is an error object, in which case, as usual, the error object only contains the `error` field of the input parameter object. -### composer.let(_name_, _value_, _task\_1_, _task\_2_, ...) +### composer.let({ _name_: _value_ }, _composition\_1_, _composition\_2_, ...) -`composer.let(name, value, task_1_, _task_2_, ...)` declares a new variable with name _name_ and initial value _value_ and runs a sequence of tasks in the scope of this definition. +`composer.let({ name: value }, composition_1_, _composition_2_, ...)` declares a new variable with name _name_ and initial value _value_ and runs a sequence of compositions in the scope of this definition. Variables declared with `composer.let` may be accessed and mutated by functions __running__ as part of the following sequence (irrespective of their place of definition). In other words, name resolution is [dynamic](https://en.wikipedia.org/wiki/Name_resolution_(programming_languages)#Static_versus_dynamic). If a variable declaration is nested inside a declaration of a variable with the same name, the innermost declaration masks the earlier declarations. -For example, the following composition invokes task `task` repeatedly `n` times. +For example, the following composition invokes composition `composition` repeatedly `n` times. ```javascript -composer.let('i', n, composer.while(() => i-- > 0, task)) +composer.let({ i: n }, composer.while(() => i-- > 0, composition)) ``` -Observe the first argument to the `let` composition is the quoted variable name `'i'`, whereas occurrences of the variable in the `let` body are not quoted. -We recommend expanding `let` compositions as follows to avoid confusing code editors: +Variables declared with `composer.let` are not visible to invoked actions. However, they may be passed as parameters to actions as for instance in: ```javascript -let i = 'i'; composer.let(i, n, composer.while(() => i-- > 0, task)) -``` - -Variables declared with `composer.let` are not visible to invoked actions. However, they may be passed as parameters to actions as for instance: - -```javascript -let n = 'n'; composer.let(n, 42, () => ({n}), '/whisk.system/utils/echo', (params) => { n = params.n }) +composer.let({ n: 42 }, () => ({ n }), '/whisk.system/utils/echo', params => { n = params.n }) ``` In this example, the variable `n` is exposed to the invoked action as a field of the input parameter object. Moreover, the value of the `n` field of the output parameter object is assigned back to variable `n`. ### composer.if(_condition_, _consequent_[, _alternate_]) -`composer.if(condition, consequent, alternate)` runs either the _consequent_ task if the _condition_ evaluates to true or the _alternate_ task if not. The _condition_ task and _consequent_ task or _alternate_ task are all invoked on the input parameter object for the composition. The output parameter object of the _condition_ task is discarded. +`composer.if(condition, consequent, alternate)` runs either the _consequent_ composition if the _condition_ evaluates to true or the _alternate_ composition if not. The _condition_ composition and _consequent_ composition or _alternate_ composition are all invoked on the input parameter object for the composition. The output parameter object of the _condition_ composition is discarded. -A _condition_ task evaluates to true if and only if it produces a JSON dictionary with a field `value` with the value `true` (i.e., JSON's `true` value). Other fields are ignored. Because JSON values other than dictionaries are implicitly lifted to dictionaries with a `value` field, _condition_ may be a Javascript function returning a Boolean value. +A _condition_ composition evaluates to true if and only if it produces a JSON dictionary with a field `value` with value `true`. Other fields are ignored. Because JSON values other than dictionaries are implicitly lifted to dictionaries with a `value` field, _condition_ may be a Javascript function returning a Boolean value. -An expression such as `params.n > 0` is not a valid condition (or in general a valid task). One should write instead: `(params) => params.n > 0`. +An expression such as `params.n > 0` is not a valid condition (or in general a valid composition). One should write instead `params => params.n > 0`. -The _alternate_ task may be omitted. +The _alternate_ composition may be omitted. If _condition_ fails, neither branch is executed. -For examples, the following composition divides parameter `n` by two if it is even. +For example, the following composition divides parameter `n` by two if `n` is even. ```javascript -composer.if(({n}) => n % 2 == 0, ({n}) => ({ n: n / 2 })) +composer.if(params => params.n % 2 == 0, params => { params.n /= 2 }) ``` -### composer.while(_condition_, _task_) - -`composer.while(condition, task)` runs _task_ repeatedly while _condition_ evaluates to true. The _condition_ task is evaluated before any execution of _task_. See [composer.if](#composerifcondition-consequent-alternate) for a discussion of conditions. +### composer.while(_condition_, _composition_) -A failure of _condition_ or _task_ interrupts the execution. +`composer.while(condition, composition)` runs _composition_ repeatedly while _condition_ evaluates to true. The _condition_ composition is evaluated before any execution of _composition_. See [composer.if](#composerifcondition-consequent-alternate) for a discussion of conditions. -### composer.try(_task_, _handler_) +A failure of _condition_ or _composition_ interrupts the execution. The composition returns the error object from the failed component. -`composer.try(task, handler)` runs _task_ with error handler _handler_. +### composer.repeat(_count_, _composition_) -A _task_ failure triggers the execution of _handler_ with the error object as its input parameter object. +`composer.repeat(count, composition)` runs _composition_ _count_ times. It is equivalent to a sequence with _count_ _composition_(s). -### composer.repeat(_count_, _task_) +### composer.try(_composition_, _handler_) -`composer.repeat(count, task)` runs _task_ _count_ times. It is equivalent to a sequence with _count_ _task_(s). +`composer.try(composition, handler)` runs _composition_ with error handler _handler_. -### composer.retry(_count_, _task_) +A _composition_ failure triggers the execution of _handler_ with the error object as its input parameter object. -`composer.retry(count, task)` runs _task_ and retries _task_ up to _count_ times if it fails. The output parameter object for the composition is either the output parameter object of the successful _task_ invocation or the error object produced by the last _task_ invocation. +### composer.retry(_count_, _composition_) -### composer.retain(_task_[, _flag_]) +`composer.retry(count, composition)` runs _composition_ and retries _composition_ up to _count_ times if it fails. The output parameter object for the composition is either the output parameter object of the successful _composition_ invocation or the error object produced by the last _composition_ invocation. -`composer.retain(task[, flag])` runs _task_ on the input parameter object producing an object with two fields `params` and `result` such that `params` is the input parameter object of the composition and `result` is the output parameter object of _task_. +### composer.retain(_composition_) -If _task_ fails and _flag_ is true, then the output parameter object for the composition is the combination of the input parameters with the error object. If _task_ fails and _flag_ is false or absent, then the output parameter object for the composition is only the error object. +`composer.retain(composition)` runs _composition_ on the input parameter object producing an object with two fields `params` and `result` such that `params` is the input parameter object of the composition and `result` is the output parameter object of _composition_. diff --git a/package.json b/package.json index 5bee527..2418aaa 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "openwhisk" ], "dependencies": { + "minimist": "^1.2.0", "openwhisk": "^3.11.0", "uglify-es": "^3.3.9" }, diff --git a/samples/demo.js b/samples/demo.js index 6edf5b7..82ef154 100644 --- a/samples/demo.js +++ b/samples/demo.js @@ -14,12 +14,4 @@ * limitations under the License. */ -'use strict' - -const composer = require('@ibm-functions/composer') - -// author action composition -const app = composer.if('authenticate', /* then */ 'welcome', /* else */ 'login') - -// compile action composition -composer.compile(app, 'demo.json') +composer.if('authenticate', /* then */ 'success', /* else */ 'failure') diff --git a/samples/demo.json b/samples/demo.json index 897be7e..6ea877e 100644 --- a/samples/demo.json +++ b/samples/demo.json @@ -1,33 +1,25 @@ { - "Entry": "push_0", - "States": { - "push_0": { - "Type": "Push", - "Next": "action_1" - }, - "action_1": { - "Type": "Task", - "Action": "authenticate", - "Next": "choice_0" - }, - "choice_0": { - "Type": "Choice", - "Then": "action_2", - "Else": "action_3" - }, - "action_2": { - "Type": "Task", - "Action": "welcome", - "Next": "pass_0" - }, - "action_3": { - "Type": "Task", - "Action": "login", - "Next": "pass_0" - }, - "pass_0": { - "Type": "Pass" + "composition": [ + { + "type": "if", + "test": [ + { + "type": "action", + "name": "/_/authenticate" + } + ], + "consequent": [ + { + "type": "action", + "name": "/_/success" + } + ], + "alternate": [ + { + "type": "action", + "name": "/_/failure" + } + ] } - }, - "Exit": "pass_0" -} \ No newline at end of file + ] +} diff --git a/test/test.js b/test/test.js index c792b98..9399041 100644 --- a/test/test.js +++ b/test/test.js @@ -1,20 +1,23 @@ const assert = require('assert') -const composer = require('../composer')() +const composer = require('../composer') const name = 'TestAction' +const wsk = composer.openwhisk() -// compile, deploy, and blocking invoke -const invoke = (task, params = {}, blocking = true) => task.deploy(name).then(() => composer.wsk.actions.invoke({ name, params, blocking })) +// deploy action +const define = action => wsk.actions.delete(action.name).catch(() => { }).then(() => wsk.actions.create(action)) + +// deploy and invoke composition +const invoke = (task, params = {}, blocking = true) => wsk.compositions.deploy(task, name).then(() => wsk.actions.invoke({ name, params, blocking })) describe('composer', function () { this.timeout(20000) before('deploy test actions', function () { - return Promise.all([ - composer.action('echo', { action: 'const main = x=>x' }).deploy(), - composer.action('DivideByTwo', { action: 'function main({n}) { return { n: n / 2 } }' }).deploy(), - composer.action('TripleAndIncrement', { action: 'function main({n}) { return { n: n * 3 + 1 } }' }).deploy(), - composer.action('isNotOne', { action: 'function main({n}) { return { value: n != 1 } }' }).deploy(), - composer.action('isEven', { action: 'function main({n}) { return { value: n % 2 == 0 } }' }).deploy()]) + return define({ name: 'echo', action: 'const main = x=>x' }) + .then(() => define({ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' })) + .then(() => define({ name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' })) + .then(() => define({ name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' })) + .then(() => define({ name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' })) }) describe('blocking invocations', function () { @@ -27,36 +30,36 @@ describe('composer', function () { return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) }) - it('action name must parse to fully qualified', function() { + it('action name must parse to fully qualified', function () { let combos = [ - { n: '', s: false, e: 'Name is not specified' }, - { n: ' ', s: false, e: 'Name is not specified' }, - { n: '/', s: false, e: 'Name is not valid' }, - { n: '//', s: false, e: 'Name is not valid' }, - { n: '/a', s: false, e: 'Name is not valid' }, - { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, - { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, - { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, - { n: '/a/ /b', s: false, e: 'Name is not valid' }, - { n: 'a', e: false, s: '/_/a' }, - { n: 'a/b', e: false, s: '/_/a/b' }, - { n: 'a/b/c', e: false, s: '/a/b/c' }, - { n: '/a/b', e: false, s: '/a/b' }, - { n: '/a/b/c', e: false, s: '/a/b/c' } + { n: '', s: false, e: 'Name is not specified' }, + { n: ' ', s: false, e: 'Name is not specified' }, + { n: '/', s: false, e: 'Name is not valid' }, + { n: '//', s: false, e: 'Name is not valid' }, + { n: '/a', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, + { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/ /b', s: false, e: 'Name is not valid' }, + { n: 'a', e: false, s: '/_/a' }, + { n: 'a/b', e: false, s: '/_/a/b' }, + { n: 'a/b/c', e: false, s: '/a/b/c' }, + { n: '/a/b', e: false, s: '/a/b' }, + { n: '/a/b/c', e: false, s: '/a/b/c' } ] - combos.forEach(({n, s, e}) => { - if (s) { - // good cases - assert.ok(composer.action(n).composition[0].name, s) - } else { - // error cases - try { - composer.action(n) - assert.fail() - } catch (error) { - assert.ok(error.message == e) - } - } + combos.forEach(({ n, s, e }) => { + if (s) { + // good cases + assert.ok(composer.action(n).composition[0].name, s) + } else { + // error cases + try { + composer.action(n) + assert.fail() + } catch (error) { + assert.ok(error.message == e) + } + } }) }) From 8bbda5513b61980418530531ca3fb19dfd5c1319 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Mon, 26 Feb 2018 23:53:31 -0500 Subject: [PATCH 55/62] Fix links --- docs/COMPOSER.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/COMPOSER.md b/docs/COMPOSER.md index b71ad70..8bc86a9 100644 --- a/docs/COMPOSER.md +++ b/docs/COMPOSER.md @@ -1,6 +1,6 @@ # Composer -The [`composer`](../composer.js) Node.js module makes it possible define action [compositions](Compositions) using [combinators](Combinators). +The [`composer`](../composer.js) Node.js module makes it possible define action [compositions](#compositions) using [combinators](#combinators). ## Installation @@ -56,7 +56,7 @@ The entry with the earliest start time (`4f91f9ed0d874aaa91f9ed0d87baaa07`) summ Compositions are implemented by means of OpenWhisk conductor actions. The documentation of [conductor actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md) discusses activation records in greater details. -## JSON format +## Compositions The `compose` command when not invoked with the `--deploy` option returns the composition encoded as a JSON dictionary: ``` @@ -112,8 +112,8 @@ The `composer` module offers a number of combinators to define compositions: | [`action`](#composeractionname) | action | `composer.action('echo')` | | [`function`](#composerfunctionfun) | function | `composer.function(({ x, y }) => ({ product: x * y }))` | | [`literal` or `value`](#composerliteralvalue) | constant value | `composer.literal({ message: 'Hello, World!' })` | -| [`sequence` or `seq`](#composersequencecomposition1-composition2) | sequence | `composer.sequence('foo', 'bar')` | -| [`let`](#composerlet-name-value-composition1-composition2) | variable declarations | `composer.let({ count: 3, message: 'hello' }, ...)` | +| [`sequence` or `seq`](#composersequencecomposition_1-composition_2) | sequence | `composer.sequence('foo', 'bar')` | +| [`let`](#composerlet-name-value-composition_1-composition_2) | variable declarations | `composer.let({ count: 3, message: 'hello' }, ...)` | | [`if`](#composerifcondition-consequent-alternate) | conditional | `composer.if('authenticate', 'success', 'failure')` | | [`while`](#composerwhilecondition-composition) | loop | `composer.while('notEnough', 'doMore')` | | [`dowhile`](#TODO) | loop at least once | `composer.dowhile('fetchData', 'needMoreData')` | From 4c396766476490e493851bfa58918cbab8bfa2d3 Mon Sep 17 00:00:00 2001 From: rodric rabbah Date: Tue, 27 Feb 2018 08:48:52 -0500 Subject: [PATCH 56/62] Add deployment of openwhisk to Travis. (#4) --- .travis.yml | 4 ++++ travis/setup.sh | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100755 travis/setup.sh diff --git a/.travis.yml b/.travis.yml index 39399f4..fad99c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,10 @@ language: node_js node_js: - "6" +services: + - docker +script: + - "./travis/setup.sh" env: global: diff --git a/travis/setup.sh b/travis/setup.sh new file mode 100755 index 0000000..b44d8ee --- /dev/null +++ b/travis/setup.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -e + +# Build script for Travis-CI. + +SCRIPTDIR=$(cd $(dirname "$0") && pwd) +ROOTDIR="$SCRIPTDIR/.." +IMAGE_PREFIX="composer" +WHISKDIR="$ROOTDIR/openwhisk" + +# OpenWhisk stuff +cd $ROOTDIR +git clone --depth=1 https://github.com/apache/incubator-openwhisk.git openwhisk +cd openwhisk +./tools/travis/setup.sh + +# Pull down images +docker pull openwhisk/controller +docker tag openwhisk/controller ${IMAGE_PREFIX}/controller +docker pull openwhisk/invoker +docker tag openwhisk/invoker ${IMAGE_PREFIX}/invoker +docker pull openwhisk/nodejs6action +docker tag openwhisk/nodejs6action ${IMAGE_PREFIX}/nodejs6action + +# Deploy OpenWhisk +cd $WHISKDIR/ansible +ANSIBLE_CMD="ansible-playbook -i ${WHISKDIR}/ansible/environments/local -e docker_image_prefix=${IMAGE_PREFIX}" +$ANSIBLE_CMD setup.yml +$ANSIBLE_CMD prereq.yml +$ANSIBLE_CMD couchdb.yml +$ANSIBLE_CMD initdb.yml +$ANSIBLE_CMD wipe.yml +$ANSIBLE_CMD openwhisk.yml -e cli_installation_mode=remote + +docker images +docker ps + +cat $WHISKDIR/whisk.properties +curl -s -k https://172.17.0.1 | jq . +curl -s -k https://172.17.0.1/api/v1 | jq . + +#Deployment +WHISK_APIHOST="172.17.0.1" +WHISK_AUTH=`cat ${WHISKDIR}/ansible/files/auth.guest` +WHISK_CLI="${WHISKDIR}/bin/wsk -i" + +${WHISK_CLI} property set --apihost ${WHISK_APIHOST} --auth ${WHISK_AUTH} +${WHISK_CLI} property get + From 925e231773ee7b7bf4a76c48e8a2b4ebc3f4622d Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 27 Feb 2018 08:56:42 -0500 Subject: [PATCH 57/62] Travis --- .travis.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index fad99c7..161497c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,5 @@ node_js: - "6" services: - docker -script: +before_script: - "./travis/setup.sh" - -env: - global: -# - __OW_API_HOST="OpenWhisk host" -# - __OW_API_KEY="OpenWhisk API key" From a2fb5081fd78b63f373f96a805a623313d1a4bbc Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 27 Feb 2018 09:57:28 -0500 Subject: [PATCH 58/62] Travis self-signed certificate fix --- .travis.yml | 3 ++- composer.js | 10 +++++----- test/test.js | 2 +- travis/setup.sh | 3 +-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 161497c..d43578e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,4 +4,5 @@ node_js: services: - docker before_script: - - "./travis/setup.sh" + - ./travis/setup.sh + - export IGNORE_CERTS=true diff --git a/composer.js b/composer.js index d908b22..70e7e16 100644 --- a/composer.js +++ b/composer.js @@ -131,10 +131,9 @@ class Compositions { class Composer { openwhisk(options) { - // try to extract apihost and key from wskprops + // try to extract apihost and key first from whisk property file file and then from process.env let apihost let api_key - let ignore_certs try { const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') @@ -147,14 +146,15 @@ class Composer { apihost = parts[1] } else if (parts[0] === 'AUTH') { api_key = parts[1] - } else if (parts[0] === 'INSECURE_SSL') { - ignore_certs = parts[1] === 'true' } } } } catch (error) { } - const wsk = require('openwhisk')(Object.assign({ apihost, api_key, ignore_certs }, options)) + if (process.env.__OW_API_HOST) apihost = process.env.__OW_API_HOST + if (process.env.__OW_API_KEY) api_key = process.env.__OW_API_KEY + + const wsk = require('openwhisk')(Object.assign({ apihost, api_key }, options)) wsk.compositions = new Compositions(wsk) return wsk } diff --git a/test/test.js b/test/test.js index 9399041..597602a 100644 --- a/test/test.js +++ b/test/test.js @@ -1,7 +1,7 @@ const assert = require('assert') const composer = require('../composer') const name = 'TestAction' -const wsk = composer.openwhisk() +const wsk = composer.openwhisk({ ignore_certs: process.env.IGNORE_CERTS && process.env.IGNORE_CERTS !== 'false' && process.env.IGNORE_CERTS !== '0' }) // deploy action const define = action => wsk.actions.delete(action.name).catch(() => { }).then(() => wsk.actions.create(action)) diff --git a/travis/setup.sh b/travis/setup.sh index b44d8ee..e345026 100755 --- a/travis/setup.sh +++ b/travis/setup.sh @@ -39,11 +39,10 @@ cat $WHISKDIR/whisk.properties curl -s -k https://172.17.0.1 | jq . curl -s -k https://172.17.0.1/api/v1 | jq . -#Deployment +# Setup WHISK_APIHOST="172.17.0.1" WHISK_AUTH=`cat ${WHISKDIR}/ansible/files/auth.guest` WHISK_CLI="${WHISKDIR}/bin/wsk -i" ${WHISK_CLI} property set --apihost ${WHISK_APIHOST} --auth ${WHISK_AUTH} ${WHISK_CLI} property get - From 62239213ba5244484b220b9438e902bbbfb68d01 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 27 Feb 2018 11:18:57 -0500 Subject: [PATCH 59/62] Travis tweak --- .travis.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index d43578e..d2a16b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,10 @@ language: node_js node_js: - - "6" + - 6 services: - docker +env: + - global: + - IGNORE_CERTS=true before_script: - ./travis/setup.sh - - export IGNORE_CERTS=true From 13b409be13556781caf82992cc4332c056d97680 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 27 Feb 2018 11:39:32 -0500 Subject: [PATCH 60/62] Travis fix again --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d2a16b0..2d444b3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ node_js: services: - docker env: - - global: + global: - IGNORE_CERTS=true before_script: - ./travis/setup.sh From a47b0ae49f6bbc1853d50b757560902330d236c0 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 27 Feb 2018 11:41:58 -0500 Subject: [PATCH 61/62] README tweak --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d80d3d8..f25b90f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ flow and automatic state management. Composer helps express cloud-native apps that are serverless by construction: scale automatically, pay as you go and not for idle time. - The [IBM Cloud functions shell](https://github.com/ibm-functions/shell) offers a CLI and graphical interface for fast, incremental, iterative, and local development of serverless apps. Some additional highlights @@ -42,6 +41,8 @@ you to [join us on slack](http://ibm.biz/composer-users). This repository includes: * a [composer](composer.js) node.js module to author compositions using JavaScript, - * a [tutorial](docs/README.md) for getting started with composer in the [docs](docs) folder, + * a [compose](bin/compose) shell script for deploying compositions, + * a [tutorial](docs/README.md), + * a [reference manual](docs/COMPOSER.md), * example compositions in the [samples](samples) folder, * tests in the [test](test) folder. From aed6ac413b9ca70a21cb2ad5c02d6eef88ba8f30 Mon Sep 17 00:00:00 2001 From: Olivier Tardieu Date: Tue, 27 Feb 2018 16:20:49 -0500 Subject: [PATCH 62/62] Bad merge fix --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index dd8e9d8..c13ce0b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,9 +6,6 @@ services: env: global: - IGNORE_CERTS=true -<<<<<<< HEAD -======= - REDIS=redis://172.17.0.1:6379 ->>>>>>> master before_script: - ./travis/setup.sh