diff --git a/app/content/js/repl.js b/app/content/js/repl.js index 33611b53..7256d315 100644 --- a/app/content/js/repl.js +++ b/app/content/js/repl.js @@ -86,7 +86,7 @@ const formatOneListResult = options => (entity, idx, A) => { prefix.appendChild(prettyType)*/ /** add a cell to the current row of the list view we] are generating. "entityName" is the current row */ - const addCell = (className, value, innerClassName='', parent=entityName, onclick, watch, key) => { + const addCell = (className, value, innerClassName='', parent=entityName, onclick, watch, key, fontawesome) => { const cell = document.createElement('span'), inner = document.createElement('span') @@ -98,7 +98,13 @@ const formatOneListResult = options => (entity, idx, A) => { inner.setAttribute('data-key', key) } - if (value) { + if (fontawesome) { + const icon = document.createElement('i') + inner.appendChild(icon) + icon.className = fontawesome + inner.setAttribute('data-value', value) // in case tests need the actual value, not the icon + + } else if (value) { Promise.resolve(value) .then(value => inner.appendChild(value.nodeName ? value : document.createTextNode(value.toString()))) } else { @@ -174,7 +180,7 @@ const formatOneListResult = options => (entity, idx, A) => { // add any attributes that should appear *before* the name column if (entity.beforeAttributes) { - entity.beforeAttributes.forEach(({key, value, css='', outerCSS='', onclick}) => addCell(outerCSS, value, css, undefined, onclick, undefined, key)) + entity.beforeAttributes.forEach(({key, value, css='', outerCSS='', onclick, fontawesome}) => addCell(outerCSS, value, css, undefined, onclick, undefined, key, fontawesome)) } // now add the clickable name @@ -219,7 +225,7 @@ const formatOneListResult = options => (entity, idx, A) => { // case-specific cells // if (entity.attributes) { - entity.attributes.forEach(({key, value, css='', outerCSS='', watch, onclick}) => addCell(outerCSS, value, css, undefined, onclick, watch, key)) + entity.attributes.forEach(({key, value, css='', outerCSS='', watch, onclick, fontawesome}) => addCell(outerCSS, value, css, undefined, onclick, watch, key, fontawesome)) } else if (entity.type === 'actions') { // action-specific cells diff --git a/app/plugins/ui/commands/openwhisk-core.js b/app/plugins/ui/commands/openwhisk-core.js index cd4990d0..ee5316e3 100644 --- a/app/plugins/ui/commands/openwhisk-core.js +++ b/app/plugins/ui/commands/openwhisk-core.js @@ -86,6 +86,8 @@ let localStorageKey = 'wsk.apihost', localStorageKeyIgnoreCerts = 'wsk.apihost.ignoreCerts', apiHost = process.env.__OW_API_HOST || wskprops.APIHOST || localStorage.getItem(localStorageKey) || 'https://openwhisk.ng.bluemix.net', auth = process.env.__OW_API_KEY || wskprops.AUTH, + apigw_token = process.env.__OW_APIGW_TOKEN || wskprops.APIGW_ACCESS_TOKEN, + apigw_space_guid = process.env.__OW_APIGW_SPACE_GUID || wskprops.APIGW_SPACE_GUID, ow let userRequestedIgnoreCerts = localStorage.getItem(localStorageKeyIgnoreCerts) !== undefined @@ -94,16 +96,20 @@ let ignoreCerts = apiHost => userRequestedIgnoreCerts || apiHost.indexOf('localh /** these are the module's exported functions */ let self = {} -debug('initOW') const initOW = () => { - ow = self.ow = openwhisk({ + const owConfig = { apihost: apiHost, api_key: auth, + apigw_token, apigw_space_guid, ignore_certs: ignoreCerts(apiHost) - }) + } + debug('initOW', owConfig) + ow = self.ow = openwhisk(owConfig) + ow.api = ow.routes + delete ow.routes + debug('initOW done') } if (apiHost && auth) initOW() -debug('initOW done') /** is a given entity type CRUDable? i.e. does it have get and update operations, and parameters and annotations properties? */ const isCRUDable = { @@ -514,6 +520,128 @@ const standardViewModes = (defaultMode, fn) => { } } +/** flatten an array of arrays */ +const flatten = arrays => [].concat.apply([], arrays) + +/** api gateway actions */ +specials.api = { + get: (options, argv) => { + if (!options) return + const maybeVerb = argv[1] + const split = options.name.split('/') + let path = options.name + if (split.length > 0) { + options.name = `/${split[1]}` + path = `/${split[2]}` + } + return { + postprocess: res => { + debug('raw output of api get', res) + const { apidoc } = res.apis[0].value + const { basePath } = apidoc + const apipath = apidoc.paths[path] + const verb = maybeVerb || Object.keys(apipath)[0] + const { action:name, namespace } = apipath[verb]['x-openwhisk'] + debug('api details', namespace, name, verb) + + return repl.qexec(`wsk action get "/${namespace}/${name}"`) + .then(action => Object.assign(action, { + name, namespace, + packageName: `${verb} ${basePath}${path}` + })) + } + } + }, + create: (options, argv) => { + if (argv && argv.length === 3) { + options.basepath = options.name + options.relpath = argv[0] + options.operation = argv[1] + options.action = argv[2] + } else if (argv && argv.length === 2) { + options.relpath = options.name + options.operation = argv[0] + options.action = argv[1] + } else if (options && options['config-file']) { + //fs.readFileSync(options['config-file']) + throw new Error('config-file support not yet implemented') + } + + return { + postprocess: ({apidoc}) => { + const { basePath } = apidoc + const path = Object.keys(apidoc.paths)[0] + const api = apidoc.paths[path] + const verb = Object.keys(api)[0] + const { action:name, namespace} = api[verb]['x-openwhisk'] + + return repl.qexec(`wsk action get "/${namespace}/${name}"`) + .then(action => Object.assign(action, { + name, namespace, + packageName: `${verb} ${basePath}${path}` + })) + } + } + }, + list: () => { + return { + // turn the result into an entity tuple model + postprocess: res => { + debug('raw output of api list', res) + + // main list for each api + return flatten((res.apis || []).map(({value}) => { + // one sublist for each path + const basePath = value.apidoc.basePath + const baseUrl = value.gwApiUrl + + return flatten(Object.keys(value.apidoc.paths).map(path => { + const api = value.apidoc.paths[path] + + // one sub-sublist for each verb of the api + return Object.keys(api).map(verb => { + const { action, namespace } = api[verb]['x-openwhisk'] + const name = `${basePath}${path}` + const url = `${baseUrl}${path}` + const actionFqn = `/${namespace}/${action}` + + // here is the entity for that api/path/verb: + return { + name, namespace, + onclick: () => { + return repl.pexec(`wsk api get ${repl.encodeComponent(name)} ${verb}`) + }, + attributes: [ + { key: 'verb', value: verb }, + { key: 'action', value: action, onclick: () => repl.pexec(`wsk action get ${repl.encodeComponent(actionFqn)}`) }, + { key: 'url', value: url, fontawesome: 'fas fa-external-link-square-alt', + css: 'clickable clickable-blatant', onclick: () => window.open(url, '_blank') }, + { key: 'copy', fontawesome: 'fas fa-clipboard', css: 'clickable clickable-blatant', + onclick: evt => { + const target = evt.currentTarget + require('electron').clipboard.writeText(url) + + const svg = target.querySelector('svg') + svg.classList.remove('fa-clipboard') + svg.classList.add('fa-clipboard-check') + + setTimeout(() => { + const svg = target.querySelector('svg') + svg.classList.remove('fa-clipboard-check') + svg.classList.add('fa-clipboard') + }, 1500) + } + } + ] + } + }) + })) + })) + } + } + } +} + const actionSpecificModes = [{ mode: 'code', defaultMode: true }, { mode: 'limits' }] specials.actions = { get: standardViewModes(actionSpecificModes), @@ -845,6 +973,9 @@ const executor = (_entity, _verb, verbSynonym, commandTree, preflight) => (block } } + // postprocess the output of openwhisk; default is do nothing + let postprocess = x=>x + if (specials[entity] && specials[entity][verb]) { const res = specials[entity][verb](options, argv.slice(restIndex), verb) if (res && res.verb) { @@ -857,6 +988,11 @@ const executor = (_entity, _verb, verbSynonym, commandTree, preflight) => (block if (res && res.options) { options = res.options } + + if (res && res.postprocess) { + // postprocess the output of openwhisk + postprocess = res.postprocess + } } // process the entity-naming "nominal" argument //if (!(syn_options && syn_options.noNominalArgument) && argv_without_options[idx]) { @@ -933,6 +1069,7 @@ const executor = (_entity, _verb, verbSynonym, commandTree, preflight) => (block return preflight(verb, options) .then(options => ow[entity][verb](options)) + .then(postprocess) .then(response => { // amend the history entry with a selected subset of the response if (execOptions && execOptions.history) { @@ -1065,6 +1202,7 @@ module.exports = (commandTree, prequire) => { debug('update', options) try { return ow[entity.type].update(options) + .then(postprocess) .then(addPrettyType(entity.type, 'update', entity.name)) .catch(err => { console.error(`error in wsk::update ${err}`) diff --git a/app/plugins/ui/commands/openwhisk-usage.js b/app/plugins/ui/commands/openwhisk-usage.js index b34ecded..db128f4e 100644 --- a/app/plugins/ui/commands/openwhisk-usage.js +++ b/app/plugins/ui/commands/openwhisk-usage.js @@ -240,6 +240,64 @@ module.exports = { related: all.except('wsk rule') }, + api: { title: 'API Gateway operations', + header: 'These commands will help you to work with routes and the API Gateway.', + example: 'wsk api ', + commandPrefix: 'wsk api', + nRowsInViewport: 4, // list all four, since we have a short list + available: [{ command: 'list', strict: 'list', + docs: 'list all APIs', + example: 'wsk api list', + optional: skipAndLimit, + parents: context('api') + }, + { command: 'get', strict: 'get', + docs: 'get API details', + example: 'wsk api get ', + oneof: [ + { name: 'api', docs: 'the name of an API' }, + { name: 'path', docs: 'the full base/path route an API' } + ], + optional: [ + { name: 'verb', positional: true, docs: 'the verb to show' }, + { name: '--format', docs: 'specify the API output TYPE, either json or yaml', + allowed: ['json', 'yaml'], defaultValue: 'json'}, + { name: '--full', alias: '-f', docs: 'display full API configuration details' } + ], + parents: context('api') + }, + { command: 'delete', strict: 'delete', + docs: 'delete an API', + example: 'wsk api delete ', + required: [ + { name: 'api', docs: 'the name of an API' } + ], + optional: [ + { name: 'path', positional: true, docs: 'the path of the API' }, + { name: 'verb', positional: true, docs: 'the verb of the API' } + ], + parents: context('api') + }, + { command: 'create', strict: 'create', + docs: 'create a new API', + example: 'wsk api create <[base] path verb action>', + required: [ + { name: 'path', docs: 'path for the API' }, + { name: 'verb', docs: 'the HTTP method' }, + { name: 'action', docs: 'the OpenWhisk action to invoke' } + ], + optional: [ + { name: 'base', positional: true, docs: 'base path for the API' }, + { name: '--apiname', alias: '-n', docs: 'friendly name of the API' }, + { name: '--config-file', alias: '-c', docs: 'file containing API configuration in swagger JSON format' }, + { name: '--response-type', docs: 'set the web action response type', + allowed: ['http', 'json', 'text', 'svg'], defaultValue: 'json' }, + ], + parents: context('api') + } + ] + }, + triggers: { title: 'Trigger operations', header: 'These commands will help you to work with triggers.', example: 'wsk trigger ', diff --git a/tests/lib/ui.js b/tests/lib/ui.js index e6eaed56..17a3eac9 100644 --- a/tests/lib/ui.js +++ b/tests/lib/ui.js @@ -85,6 +85,7 @@ const expectOK = (appAndCount, opt) => { // expect exactly one entry return app.client.getText(selectors.LIST_RESULTS_BY_NAME_N(N - 1)) .then(name => assert.equal(name, opt)) + .then(() => N - 1) } else if (util.isArray(opt)) { // expect several entries, of which opt is one return app.client.getText(selectors.LIST_RESULTS_BY_NAME_N(N - 1)) @@ -115,7 +116,7 @@ const expectOK = (appAndCount, opt) => { return N - 1 } }) - .then(res => opt && (opt.selector || opt.passthrough) ? res : app) // return res rather than app, if requested + .then(res => opt && (opt.selector || opt.passthrough || typeof opt === 'string') ? res : app) // return res rather than app, if requested .catch(err => { console.log(err) common.oops({ app: app })(err) @@ -460,3 +461,5 @@ const waitForActivationOrSession = entityType => (app, activationId, { name='' } } exports.waitForActivation = waitForActivationOrSession('activation') exports.waitForSession = waitForActivationOrSession('session') + +exports.apiHost = constants.API_HOST diff --git a/tests/tests/passes/04/apis.js b/tests/tests/passes/04/apis.js new file mode 100644 index 00000000..44f61ea7 --- /dev/null +++ b/tests/tests/passes/04/apis.js @@ -0,0 +1,58 @@ +/* + * Copyright 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. + * 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. + */ + +const common = require('../../../lib/common'), + rp = common.rp, + openwhisk = require('../../../lib/openwhisk'), + ui = require('../../../lib/ui'), + assert = require('assert'), + keys = ui.keys, + cli = ui.cli, + sidecar = ui.sidecar, + actionName1 = 'foo1' + +describe('Create api gateway', function() { + before(common.before(this)) + after(common.after(this)) + + it('should have an active repl', () => cli.waitForRepl(this.app)) + + it('should create an echo action', () => cli.do(`let echo = x=>x`, this.app) + .then(cli.expectOK) + .then(sidecar.expectOpen) + .then(sidecar.expectShowing('echo')) + .catch(common.oops(this))) + + it('should webbify the action', () => cli.do(`webbify`, this.app) + .then(cli.expectOK) + .then(sidecar.expectOpen) + .then(sidecar.expectShowing('echo')) + .catch(common.oops(this))) + + it('should create the api', () => cli.do(`wsk api create /hello /world get echo`, this.app) + .then(cli.expectOK) + .then(sidecar.expectOpen) + .then(sidecar.expectShowing('echo')) + .catch(common.oops(this))) + + it('should list the api', () => cli.do(`wsk api list`, this.app) + .then(cli.expectOKWithOnly('/hello/world')) + .then(N => this.app.client.getAttribute(`${ui.selectors.LIST_RESULTS_N(N)} [data-key="url"]`, "data-value")) + .then(href => href.replace('http://172.17.0.1', ui.apiHost.replace('https://', 'http://'))) + .then(href => rp({ url: `${href}?foo=bar`, rejectUnauthorized: false })) + .then(ui.expectSubset({foo: 'bar'})) + .catch(common.oops(this))) +})