Make it easier to create/update action sequences. (#172)

* Make it easier to create/update action sequences.

Adds top-level `sequence` parameter to actions.create/update method.
Removes need to manually construct action request body.
diff --git a/README.md b/README.md
index 57916cb..f31e6d3 100644
--- a/README.md
+++ b/README.md
@@ -223,12 +223,7 @@
 const actionName = '/mynamespace/reverseWords'
 const name = 'reverse'
 
-ow.actions.create({ name, action: {
-  exec: {
-    kind: 'sequence',
-    components: [ actionName ]
-  }
-}})
+ow.actions.create({ name, sequence: [ actionName ] })
 ```
 
 ### retrieve action resource
@@ -459,6 +454,32 @@
 ow.actions.create([{...}, {...}])
 ```
 
+### create & update action sequence
+
+```javascript
+ow.actions.create({name: '...', sequence: ["action_name", "next_action", ...]})
+ow.actions.update({name: '...', sequence: ["action_name", "next_action", ...]})
+```
+
+The following mandatory parameters are supported:
+
+- `name` - action identifier
+- `sequence` - Array containing JS strings with action identifiers to use in sequence. This can be a full or relative action identifier (e.g. `action-name` or `/namespace/package/action-name`).
+
+The following optional parameters are supported:
+
+- `namespace` - set custom namespace for endpoint
+- `params` - object containing default parameters for the action (default: `{}`)
+- `annotations` - object containing annotations for the action (default: `{}`)
+- `limits` - object containing limits for the action (default: `{}`)
+- `version` - set semantic version of the action. If parameter is empty when create new action openwisk generate 0.0.1 value when update an action increase the patch version.
+
+If you pass in an array for the first parameter, the `create` call will be executed for each array item. The function returns a Promise which resolves with the results when all operations have finished.
+
+```javascript
+ow.actions.create([{...}, {...}])
+```
+
 ### fire trigger
 
 ```javascript
diff --git a/lib/actions.js b/lib/actions.js
index 58ee064..b7d7ea7 100644
--- a/lib/actions.js
+++ b/lib/actions.js
@@ -44,10 +44,7 @@
     return super.create(options)
   }
 
-  actionBody (options) {
-    if (!options.hasOwnProperty('action')) {
-      throw new Error(messages.MISSING_ACTION_BODY_ERROR)
-    }
+  actionBodyWithCode (options) {
     const body = {exec: {kind: options.kind || 'nodejs:default', code: options.action}}
 
     // allow options to override the derived exec object
@@ -57,10 +54,45 @@
 
     if (options.action instanceof Buffer) {
       body.exec.code = options.action.toString('base64')
-    } else if (typeof options.action === 'object') {
+    }
+
+    return body
+  }
+
+  actionBodyWithSequence (options) {
+    if (!(options.sequence instanceof Array)) {
+      throw new Error(messages.INVALID_SEQ_PARAMETER)
+    }
+
+    if (options.sequence.length === 0) {
+      throw new Error(messages.INVALID_SEQ_PARAMETER_LENGTH)
+    }
+
+    const body = {exec: {kind: 'sequence', components: options.sequence}}
+    return body
+  }
+
+  actionBody (options) {
+    const isCodeAction = options.hasOwnProperty('action')
+    const isSequenceAction = options.hasOwnProperty('sequence')
+
+    if (!isCodeAction && !isSequenceAction) {
+      throw new Error(messages.MISSING_ACTION_OR_SEQ_BODY_ERROR)
+    }
+
+    if (isCodeAction && isSequenceAction) {
+      throw new Error(messages.INVALID_ACTION_AND_SEQ_PARAMETERS)
+    }
+
+    // user can manually define & control exact action definition by passing in an object
+    if (isCodeAction && typeof options.action === 'object' &&
+      (!(options.action instanceof Buffer))) {
       return options.action
     }
 
+    const body = isCodeAction
+      ? this.actionBodyWithCode(options) : this.actionBodyWithSequence(options)
+
     if (typeof options.params === 'object') {
       body.parameters = Object.keys(options.params).map(key => ({key, value: options.params[key]}))
     }
diff --git a/lib/messages.js b/lib/messages.js
index a1873f9..0f9c23b 100644
--- a/lib/messages.js
+++ b/lib/messages.js
@@ -7,7 +7,10 @@
   MISSING_ACTION_ERROR: 'Missing mandatory actionName parameter from options.',
   INVALID_ACTION_ERROR: 'Invalid actionName parameter from options. Should be "action", "/namespace/action" or "/namespace/package/action".',
   INVALID_RESOURCE_ERROR: 'Invalid resource identifier from options. Should be "resource", "/namespace/resource" or "/namespace/package/resource".',
-  MISSING_ACTION_BODY_ERROR: 'Missing mandatory action parameter from options.',
+  INVALID_SEQ_PARAMETER: 'Invalid sequence parameter from options. Must be an array.',
+  INVALID_SEQ_PARAMETER_LENGTH: 'Invalid sequence parameter from options. Array must not be empty.',
+  INVALID_ACTION_AND_SEQ_PARAMETERS: 'Invalid options parameters, contains both "action" and "sequence" parameters in options.',
+  MISSING_ACTION_OR_SEQ_BODY_ERROR: 'Missing mandatory action or sequence parameter from options.',
   MISSING_RULE_ERROR: 'Missing mandatory ruleName parameter from options.',
   MISSING_TRIGGER_ERROR: 'Missing mandatory triggerName parameter from options.',
   MISSING_PACKAGE_ERROR: 'Missing mandatory packageName parameter from options.',
diff --git a/test/integration/actions.test.js b/test/integration/actions.test.js
index 95df0b4..0e72c89 100644
--- a/test/integration/actions.test.js
+++ b/test/integration/actions.test.js
@@ -224,3 +224,27 @@
     })
   }).catch(errors)
 })
+
+test('create, get and delete sequence action', t => {
+  const errors = err => {
+    console.log(err)
+    t.fail()
+  }
+
+  const actions = new Actions(new Client(options))
+  return actions.create({
+    actionName: 'my_sequence',
+    sequence: ['/whisk.system/utils/echo']
+  }).then(result => {
+    t.is(result.name, 'my_sequence')
+    t.is(result.namespace, NAMESPACE)
+    t.is(result.exec.kind, 'sequence')
+    t.deepEqual(result.exec.components, ['/whisk.system/utils/echo'])
+    return actions.get({actionName: 'my_sequence'}).then(actionResult => {
+      t.is(actionResult.name, 'my_sequence')
+      t.is(actionResult.namespace, NAMESPACE)
+      t.pass()
+      return actions.delete({actionName: 'my_sequence'}).catch(errors)
+    }).catch(errors)
+  }).catch(errors)
+})
diff --git a/test/unit/actions.test.js b/test/unit/actions.test.js
index 4873d32..63c7f72 100644
--- a/test/unit/actions.test.js
+++ b/test/unit/actions.test.js
@@ -430,6 +430,82 @@
   return actions.create({name: '12345', action, version})
 })
 
+test('create a new sequence action', t => {
+  t.plan(4)
+  const ns = '_'
+  const client = {}
+  const sequence = ['/ns/action', '/ns/another_action', '/ns/final_action']
+
+  const actions = new Actions(client)
+
+  client.request = (method, path, options) => {
+    t.is(method, 'PUT')
+    t.is(path, `namespaces/${ns}/actions/12345`)
+    t.deepEqual(options.qs, {})
+    t.deepEqual(options.body, {exec: {kind: 'sequence', components: sequence}})
+  }
+
+  return actions.create({name: '12345', sequence})
+})
+
+test('create a new sequence action with additional options', t => {
+  t.plan(4)
+  const ns = '_'
+  const client = {}
+  const sequence = ['/ns/action', '/ns/another_action', '/ns/final_action']
+  const annotations = {
+    foo: 'bar'
+  }
+  const params = {
+    foo: 'bar'
+  }
+  const limits = {
+    timeout: 300000
+  }
+
+  const actions = new Actions(client)
+
+  client.request = (method, path, options) => {
+    t.is(method, 'PUT')
+    t.is(path, `namespaces/${ns}/actions/12345`)
+    t.deepEqual(options.qs, {})
+    t.deepEqual(options.body, {exec: {kind: 'sequence', components: sequence},
+      limits,
+      parameters: [
+        {key: 'foo', value: 'bar'}
+      ],
+      annotations: [
+        { key: 'foo', value: 'bar' }
+      ]})
+  }
+
+  return actions.create({name: '12345', sequence, annotations, params, limits})
+})
+
+test('creating sequence action with invalid sequence parameter', t => {
+  const client = {}
+
+  const actions = new Actions(client)
+
+  t.throws(() => actions.create({name: '12345', sequence: 'string'}), /Invalid sequence parameter/)
+  t.throws(() => actions.create({name: '12345', sequence: { foo: 'bar' }}), /Invalid sequence parameter/)
+})
+
+test('creating sequence action with empty array', t => {
+  const client = {}
+
+  const actions = new Actions(client)
+
+  t.throws(() => actions.create({name: '12345', sequence: []}), /Invalid sequence parameter/)
+})
+
+test('creating action with both sequence and action parameters', t => {
+  const client = {}
+  const actions = new Actions(client)
+
+  t.throws(() => actions.create({name: '12345', action: 'function main() {}', sequence: 'string'}), /Invalid options parameters/)
+})
+
 test('should pass through requested User-Agent header', t => {
   t.plan(1)
   const userAgent = 'userAgentShouldPassThroughPlease'