diff --git a/README.md b/README.md index bb4d0d1..f587e24 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Usage: apigeelint [options] Options: -V, --version output the version number -s, --path Path of the proxy to analyze + -d, --download [value] Download the API proxy or sharedflow to analyze. Exclusive of -s / --path. Example: org:ORG,API:proxyname or org:ORG,sf:SHAREDFLOWNAME -f, --formatter [value] Specify formatters (default: json.js) -w, --write [value] file path to write results -e, --excluded [value] The comma separated list of tests to exclude (default: none) @@ -106,10 +107,48 @@ archive. This tool also can read and analyze these zipped bundles: apigeelint -f table.js -s path/to/your/apiproxy.zip ``` -The tool will unzip the bundle to a temporary directory, perform the analysis, +The tool will unzip the bundle into a temporary directory, perform the analysis, and then remove the temporary directory. +### Basic usage: downloading a proxy bundle to analyze + +You can ask apigeelint to export an API Proxy or Sharedflow bundle from Apigee, +and analyze the resulting zip archive. This connects to apigee.googleapis.com to +perform the export, which means it will work only with Apigee X or hybrid. + +``` +# to download and then analyze a proxy bundle +apigeelint -f table.js -d org:your-org-name,api:name-of-your-api-proxy + +# to download and then analyze a sharedflow bundle +apigeelint -f table.js -d org:your-org-name,sf:name-of-your-shared-flow +``` + +With this invocation, the tool will: +- obtain a token using the `gcloud auth print-access-token` command +- use the token to inquire the latest revision of the proxy or sharedflow +- use the token to download the bundle for the latest revision +- unzip the bundle into a temporary directory +- perform the lint analysis +- render the result +- and then remove the temporary directory + +If you do not have the [`gcloud` command line +tool](https://cloud.google.com/sdk/gcloud) installed, and available on your +path, this will fail. + + +You can also specify a token you have obtained previously: + +``` +apigeelint -f table.js -d org:your-org-name,api:name-of-your-api-proxy,token:ACCESS_TOKEN_HERE +``` + +In this case, apigeelint does not try to use `gcloud` to obtain an access token. + + + ### Using External Plugins: ``` apigeelint -x ./externalPlugins -s path/to/your/apiproxy -f table.js diff --git a/cli.js b/cli.js index a75a1a7..72795e4 100755 --- a/cli.js +++ b/cli.js @@ -24,6 +24,7 @@ const program = require("commander"), bl = require("./lib/package/bundleLinter.js"), rc = require("./lib/package/apigeelintrc.js"), pkj = require("./package.json"), + downloader = require("./lib/package/downloader.js"), bundleType = require("./lib/package/BundleTypes.js"), debug = require("debug"); @@ -77,136 +78,158 @@ const findBundle = (p) => { ); }; -program - .version(pkj.version) - .option( - "-s, --path ", - "Path of the exploded apiproxy or sharedflowbundle directory", - ) - .option("-f, --formatter [value]", "Specify formatters (default: json.js)") - .option("-w, --write [value]", "file path to write results") - .option( - "-e, --excluded [value]", - "The comma separated list of tests to exclude (default: none)", - ) - // .option("-M, --mgmtserver [value]", "Apigee management server") - // .option("-u, --user [value]", "Apigee user account") - // .option("-p, --password [value]", "Apigee password") - // .option("-o, --organization [value]", "Apigee organization") - .option( - "-x, --externalPluginsDirectory [value]", - "Relative or full path to an external plugins directory", - ) - .option( - "-q, --quiet", - "do not emit the report to stdout. (can use --write option to write to file)", - ) - .option( - "--list", - "do not execute, instead list the available plugins and formatters", - ) - .option( - "--maxWarnings [value]", - "Number of warnings to trigger nonzero exit code (default: -1)", - ) - .option("--profile [value]", "Either apigee or apigeex (default: apigee)") - .option( - "--norc", - "do not search for and use the .apigeelintrc file for settings", - ) - .option( - "--ignoreDirectives", - "ignore any directives within XML files that disable warnings", - ); +(async function main() { + program + .version(pkj.version) + .option( + "-s, --path ", + "Path of the exploded apiproxy or sharedflowbundle directory", + ) + .option( + "-d, --download [value]", + "Download the API proxy or sharedflow to analyze. Exclusive of -s / --path. Example: org:ORG,API:proxyname or org:ORG,sf:SHAREDFLOWNAME", + ) + .option("-f, --formatter [value]", "Specify formatters (default: json.js)") + .option("-w, --write [value]", "file path to write results") + .option( + "-e, --excluded [value]", + "The comma separated list of tests to exclude (default: none)", + ) + // .option("-M, --mgmtserver [value]", "Apigee management server") + // .option("-u, --user [value]", "Apigee user account") + // .option("-p, --password [value]", "Apigee password") + // .option("-o, --organization [value]", "Apigee organization") + .option( + "-x, --externalPluginsDirectory [value]", + "Relative or full path to an external plugins directory", + ) + .option( + "-q, --quiet", + "do not emit the report to stdout. (can use --write option to write to file)", + ) + .option( + "--list", + "do not scan, instead list the available plugins and formatters", + ) + .option( + "--maxWarnings [value]", + "Number of warnings to trigger nonzero exit code (default: -1)", + ) + .option("--profile [value]", "Either apigee or apigeex (default: apigee)") + .option( + "--norc", + "do not search for and use the .apigeelintrc file for settings", + ) + .option( + "--ignoreDirectives", + "ignore any directives within XML files that disable warnings", + ); -program.on("--help", function () { - console.log("\nExample: apigeelint -f table.js -s sampleProxy/apiproxy"); - console.log(""); -}); + program.on("--help", function () { + console.log("\nExamples:"); + console.log(" apigeelint -f table.js -s sampleProxy/apiproxy\n"); + console.log(" apigeelint -f table.js -s path/to/your/apiproxy.zip\n"); + console.log( + " apigeelint -f table.js --download org:my-org,api:my-proxy\n", + ); + console.log(""); + }); + + program.parse(process.argv); + + if (program.list) { + console.log("available plugins: " + bl.listRuleIds().join(", ") + "\n"); + console.log("available formatters: " + bl.listFormatters().join(", ")); + if (fs.existsSync(program.externalPluginsDirectory)) { + console.log( + "\n" + + "available external plugins: " + + bl.listExternalRuleIds(program.externalPluginsDirectory).join(", ") + + "\n", + ); + } + process.exit(0); + } -program.parse(process.argv); + if (program.download) { + if (program.path) { + console.log( + "you must specify either the -s /--path option, or the -d /--download option. Not both.", + ); + process.exit(1); + } + // This will work only with Apigee X/hybrid + program.path = await downloader.downloadBundle(program.download); + } -if (program.list) { - console.log("available plugins: " + bl.listRuleIds().join(", ") + "\n"); - console.log("available formatters: " + bl.listFormatters().join(", ")); - if (fs.existsSync(program.externalPluginsDirectory)) { + if (!program.path) { console.log( - "\n" + - "available external plugins: " + - bl.listExternalRuleIds(program.externalPluginsDirectory).join(", ") + - "\n", + "you must specify the -s option, or the long form of that: --path ", ); + process.exit(1); } - process.exit(0); -} -if (!program.path) { - console.log( - "you must specify the -s option, or the long form of that: --path ", - ); - process.exit(1); -} - -let [sourcePath, resolvedPath, sourceType] = findBundle(program.path); - -// apply RC file -if (!program.norc) { - const rcSettings = rc.readRc([".apigeelintrc"], sourcePath); - if (rcSettings) { - Object.keys(rcSettings) - .filter((key) => key != "path" && key != "list" && !program[key]) - .forEach((key) => { - debug("apigeelint:rc")(`applying [${key}] = ${rcSettings[key]}`); - program[key] = rcSettings[key]; - }); + let [sourcePath, resolvedPath, sourceType] = findBundle(program.path); + + // apply RC file + if (!program.norc) { + const rcSettings = rc.readRc([".apigeelintrc"], sourcePath); + if (rcSettings) { + Object.keys(rcSettings) + .filter((key) => key != "path" && key != "list" && !program[key]) + .forEach((key) => { + debug("apigeelint:rc")(`applying [${key}] = ${rcSettings[key]}`); + program[key] = rcSettings[key]; + }); + } } -} - -const configuration = { - debug: true, - source: { - type: sourceType, - path: resolvedPath, - sourcePath: sourcePath, - bundleType: resolvedPath.endsWith(bundleType.BundleType.SHAREDFLOW) - ? bundleType.BundleType.SHAREDFLOW - : bundleType.BundleType.APIPROXY, - }, - externalPluginsDirectory: program.externalPluginsDirectory, - excluded: {}, - maxWarnings: -1, - profile: "apigee", -}; -if (!isNaN(program.maxWarnings)) { - configuration.maxWarnings = Number.parseInt(program.maxWarnings); -} + const configuration = { + debug: true, + source: { + type: sourceType, + path: resolvedPath, + sourcePath: sourcePath, + bundleType: resolvedPath.endsWith(bundleType.BundleType.SHAREDFLOW) + ? bundleType.BundleType.SHAREDFLOW + : bundleType.BundleType.APIPROXY, + }, + externalPluginsDirectory: program.externalPluginsDirectory, + excluded: {}, + maxWarnings: -1, + profile: "apigee", + }; + + if (!isNaN(program.maxWarnings)) { + configuration.maxWarnings = Number.parseInt(program.maxWarnings); + } -if (program.formatter) { - configuration.formatter = program.formatter || "json.js"; -} + if (program.formatter) { + configuration.formatter = program.formatter || "json.js"; + } -if (program.quiet) { - configuration.output = "none"; -} + if (program.quiet) { + configuration.output = "none"; + } -if (program.ignoreDirectives) { - configuration.ignoreDirectives = true; -} + if (program.ignoreDirectives) { + configuration.ignoreDirectives = true; + } -if (program.excluded && typeof program.excluded === "string") { - configuration.excluded = program.excluded - .split(",") - .map((s) => s.trim()) - .reduce((acc, item) => ((acc[item] = true), acc), {}); -} + if (program.excluded && typeof program.excluded === "string") { + configuration.excluded = program.excluded + .split(",") + .map((s) => s.trim()) + .reduce((acc, item) => ((acc[item] = true), acc), {}); + } -if (program.write) { - configuration.writePath = program.write; -} + if (program.write) { + configuration.writePath = program.write; + } -if (program.profile) { - configuration.profile = program.profile; -} + if (program.profile) { + configuration.profile = program.profile; + } -bl.lint(configuration); + bl.lint(configuration); +})(); diff --git a/lib/package/downloader.js b/lib/package/downloader.js new file mode 100644 index 0000000..11f0e04 --- /dev/null +++ b/lib/package/downloader.js @@ -0,0 +1,110 @@ +/* + Copyright © 2024 Google LLC + + 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 + + https://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 fs = require("fs"), + { Readable } = require("stream"), + { finished } = require("stream/promises"), + path = require("path"), + tmp = require("tmp"), + child_process = require("node:child_process"), + debug = require("debug")("apigeelint:download"); + +const downloadBundle = async (downloadSpec) => { + // 0. validate the input. it should be org:ORGNAME,api:APINAME or org:ORGNAME,sf:SHAREDFLOWNAME + let parts = downloadSpec.split(","); + let invalidArgument = () => { + console.log( + "Specify the value in the form org:ORGNAME,api:APINAME or org:ORGNAME,sf:SHAREDFLOWNAME", + ); + process.exit(1); + }; + if (!parts || (parts.length != 2 && parts.length != 3)) { + invalidArgument(); + } + let orgparts = parts[0].split(":"); + if (!orgparts || orgparts.length != 2 || orgparts[0] != "org") { + invalidArgument(); + } + let assetparts = parts[1].split(":"); + if (!assetparts || assetparts.length != 2) { + invalidArgument(); + } + if (assetparts[0] != "api" && assetparts[0] != "sf") { + invalidArgument(); + } + + let providedToken = null; + if (parts.length == 3) { + let tokenParts = parts[2].split(":"); + if (!tokenParts || tokenParts.length != 2 || tokenParts[0] != "token") { + invalidArgument(); + } + providedToken = tokenParts[1]; + } + + const execOptions = { + // cwd: proxyDir, // I think i do not care + encoding: "utf8", + }; + try { + // 1. use the provided token, or get a new one using gcloud. This may fail. + let accessToken = + providedToken || + child_process.execSync("gcloud auth print-access-token", execOptions); + // 2. inquire the revisions + let flavor = assetparts[0] == "api" ? "apis" : "sharedflows"; + const urlbase = `https://apigee.googleapis.com/v1/organizations/${orgparts[1]}/${flavor}`; + const headers = { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }; + + let url = `${urlbase}/${assetparts[1]}/revisions`; + let revisionsResponse = await fetch(url, { method: "GET", headers }); + + // 3. export the latest revision + if (!revisionsResponse.ok) { + throw new Error(`HTTP error: ${revisionsResponse.status}, on GET ${url}`); + } + const revisions = await revisionsResponse.json(); + revisions.sort(); + const rev = revisions[revisions.length - 1]; + url = `${urlbase}/${assetparts[1]}/revisions/${rev}?format=bundle`; + + const tmpdir = tmp.dirSync({ + prefix: `apigeelint-download-${assetparts[0]}`, + keep: false, + }); + const pathToDownloadedAsset = path.join( + tmpdir.name, + `${assetparts[1]}-rev${rev}.zip`, + ); + const stream = fs.createWriteStream(pathToDownloadedAsset); + const { body } = await fetch(url, { method: "GET", headers }); + await finished(Readable.fromWeb(body).pipe(stream)); + return pathToDownloadedAsset; + } catch (ex) { + // Possible causes: No gcloud cli found, or myriad other circumstances. + // Show the error message and first line of stack trace. + console.log(ex.stack.split("\n", 2).join("\n")); + console.log("cannot download the bundle from Apigee. Cannot continue."); + process.exit(1); + } +}; + +module.exports = { + downloadBundle, +};