Dynamically Documenting OpenWhisk Packages

Earlier this week I was doing some work with the Cloudant package under OpenWhisk when I noticed the docs didn't include all the actions available. What I mean is, the docs currently mention the read and write actions, the changes trigger, and nothing else. Compare this to what you get when you run wsk package get /whisk.system/cloudant --summary:

package /whisk.system/cloudant: Cloudant database service
   (parameters: bluemixServiceName, dbname, host, overwrite, password, username)
 action /whisk.system/cloudant/delete-attachment: Delete document attachment from database
   (parameters: attachmentname, dbname, docid, docrev, params)
 action /whisk.system/cloudant/update-attachment: Update document attachment in database
   (parameters: attachment, attachmentname, contenttype, dbname, docid, docrev, params)
 action /whisk.system/cloudant/read-attachment: Read document attachment from database
   (parameters: attachmentname, dbname, docid, params)
 action /whisk.system/cloudant/create-attachment: Create document attachment in database
   (parameters: attachment, attachmentname, contenttype, dbname, docid, docrev, params)
 action /whisk.system/cloudant/read-changes-feed: Read Cloudant database changes feed (non-continuous)
   (parameters: dbname, params)
 action /whisk.system/cloudant/delete-query-index: Delete index from design document
   (parameters: dbname, docid, indexname, params)
 action /whisk.system/cloudant/delete-view: Delete view from design document
   (parameters: dbname, docid, params, viewname)
 action /whisk.system/cloudant/manage-bulk-documents: Create, Update, and Delete documents in bulk
   (parameters: dbname, docs, params)
 action /whisk.system/cloudant/exec-query-view: Call view in design document from database
   (parameters: dbname, docid, params, viewname)
 action /whisk.system/cloudant/exec-query-search: Execute query against Cloudant search
   (parameters: dbname, docid, indexname, search)
 action /whisk.system/cloudant/exec-query-find: Execute query against Cloudant Query index
   (parameters: dbname, query)
 action /whisk.system/cloudant/list-query-indexes: List Cloudant Query indexes from database
   (parameters: dbname)
 action /whisk.system/cloudant/create-query-index: Create a Cloudant Query index into database
   (parameters: dbname, index)
 action /whisk.system/cloudant/list-design-documents: List design documents from database
   (parameters: dbname, includedocs)
 action /whisk.system/cloudant/list-documents: List all docs from database
   (parameters: dbname, params)
 action /whisk.system/cloudant/delete-document: Delete document from database
   (parameters: dbname, docid, docrev)
 action /whisk.system/cloudant/update-document: Update document in database
   (parameters: dbname, doc, params)
 action /whisk.system/cloudant/write: Write document in database
   (parameters: dbname, doc)
 action /whisk.system/cloudant/read-document: Read document from database
   (parameters: dbname, docid, params)
 action /whisk.system/cloudant/read: Read document from database
   (parameters: dbname, id, params)
 action /whisk.system/cloudant/create-document: Create document in database
   (parameters: dbname, doc, params)
 action /whisk.system/cloudant/read-updates-feed: Read updates feed from Cloudant account (non-continuous)
   (parameters: dbname, params)
 action /whisk.system/cloudant/list-all-databases: List all Cloudant databases
 action /whisk.system/cloudant/delete-database: Delete Cloudant database
   (parameters: dbname)
 action /whisk.system/cloudant/read-database: Read Cloudant database
   (parameters: dbname)
 action /whisk.system/cloudant/create-database: Create Cloudant database
   (parameters: dbname)
 feed   /whisk.system/cloudant/changes: Database change feed
   (parameters: dbname, filter, query_params)

Normally this is the type of thing I'd trim, but I wanted to keep it all there so you can see the large set of other actions supported by the package as well. (There's already an open bug about this package.)

One of the cool features of OpenWhisk is that it supports annotations. You can apply metadata to actions, triggers, rules, and packages. That means it's possible to get information about packages, if the creator set up that metadata of course. Even without additional metadata, you can get information about what's inside a package and at least use that as a base to create documentation from. When annotations exist, you can then add them to the result for better output.

With that in mind, I built a little command line utility called "packagedoc". Here is the script - it isn't necessarily a work of art:

const openwhisk = require('openwhisk');
const handlebars = require('handlebars');
const fs = require('fs');

if(process.argv.length === 2) {
    console.log('Usage: generate <<package>> <<outputfile>> (outputfile defaults to output.html');
    process.exit(1);
}
let package = process.argv[2];

let output='./output.html';

if(process.argv.length === 4) output = process.argv[3];

console.log('Attempt to generate docs for '+package);

const api_key = process.env['__OW_API_KEY'];
let options = {apihost: 'openwhisk.ng.bluemix.net', api_key: api_key};
let ow = openwhisk(options);

ow.packages.get(package).then(result => {
    let html = generateHTML(result, package);
    fs.writeFileSync(output, html);
    
    console.log('Output written to '+output);
}).catch(err => {
    if(err.statusCode === 401) {
        console.error('Invalid API Key used.');
        process.exit(1);
    } else if(err.statusCode === 403) {
        console.error('Invalid package, or one you do not have access to.');
        process.exit();
    }
    console.log('Unhandled Error:', err);
});

function generateHTML(package, name) {
    let templateSource = fs.readFileSync('./template.html','utf-8');
    let template = handlebars.compile(templateSource);

    let s = '';

    /*
    Do a bit of normalization.
    */
    package.description = '';
    package.annotations.forEach((anno) => {
        if(anno.key === 'description') {
            package.description = anno.value;
        }
        if(anno.key === 'parameters') {
            /*
            So the main package ob's parameters is names+values(defaults), this is possible more descriptive
            so we use this to enhance the main params.
            Note - there is also a case where a annotation parameter isn't in the main list. I noticed
            wsk package get x --summary *would* include the annotation so we'll copy it over.
            */
            anno.value.forEach((param) => {
                //attempt to find existing
                let found = package.parameters.findIndex((origparam) => origparam.key === param.name);
                if(found === -1) {
                    let newParam = {
                        key:param.name,
                        value:''
                    }
                    package.parameters.push(newParam);
                    found = package.parameters.length-1;
                }
                //copy over description, required, type. Not bindtime
                if(param.description) package.parameters[found].description = param.description;
                if(param.type) package.parameters[found].type = param.type;
                if(param.required) package.parameters[found].required = param.required;
            });
        }
    });

    //work on actions
    package.actions.sort((a, b) => {
        if(a.name < b.name) return -1;
        if(a.name > b.name) return 1;
        return 0;
    });
    package.actions.forEach((action) => {
        action.annotations.forEach((anno) => {
            if(anno.key === 'description') action.description = anno.value;
            if(anno.key === 'parameters') action.parameters = anno.value;
            if(anno.key === 'sampleInput') action.sampleInput = JSON.stringify(anno.value, null, '\t');
            if(anno.key === 'sampleOutput') action.sampleOutput = JSON.stringify(anno.value, null, '\t');
        });
    });

    //feeds *seems* to be the exact same as actions, I'm copying and pasting for now, but may later make it one thing
    package.feeds.sort((a, b) => {
        if(a.name < b.name) return -1;
        if(a.name > b.name) return 1;
        return 0;
    });
    package.feeds.forEach((feed) => {
        feed.annotations.forEach((anno) => {
            if(anno.key === 'description') feed.description = anno.value;
            if(anno.key === 'parameters') feed.parameters = anno.value;
            if(anno.key === 'sampleInput') feed.sampleInput = JSON.stringify(anno.value, null, '\t');
            if(anno.key === 'sampleOutput') feed.sampleOutput = JSON.stringify(anno.value, null, '\t');
        });
    });

    return template({package:package, name:name}); 
}

The script makes use of the OpenWhisk npm package to integrate with the OpenWhisk system and fetch details on a package. Note you'll need to store your API key in an environment variable called __OW_API_KEY.

After fetching the information, which is a large JSON packet, I do a bit of normalization to the data and then pass it to a Handlebars template for rendering. Basic usage looks like this:

node generate.js /whisk.system/cloudant output/cloudant.html

Here is an example of the output: https://cfjedimaster.github.io/Serverless-Examples/packagedoc/samples/cloudant.html

I used Bootstrap for the template and applied what I thought made sense in terms of display, ordering, etc. (For the life of me I'll never get why alpha sort seems to rarely be a default.)

If you want to check it out, you can get the bits from here: https://github.com/cfjedimaster/Serverless-Examples/tree/master/packagedoc

You'll also see a samples folder with a few outputs from some of the OpenWhisk packages. What do you think - useful?

Like This?

If you like this article, please consider visiting my Amazon Wishlist or donating via PayPal to show your support. You can also subscribe to the email feed to get notified of new posts.

Want to read more like this?