diff --git a/package-lock.json b/package-lock.json index cf85efa..dc7ff78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@stoplight/spectral-runtime": "^1.1.2", "axios": "^0.27.2", "chalk": "^4.1.2", - "yaml": "^2.1.1", + "tmp": "^0.2.1", "yargs": "^17.5.1" }, "bin": { @@ -4294,7 +4294,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -4613,6 +4612,17 @@ "node": ">=8" } }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -4976,14 +4986,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, - "node_modules/yaml": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.1.tgz", - "integrity": "sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==", - "engines": { - "node": ">= 14" - } - }, "node_modules/yargs": { "version": "17.6.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", diff --git a/package.json b/package.json index f33bc03..7fae8e1 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@stoplight/spectral-runtime": "^1.1.2", "axios": "^0.27.2", "chalk": "^4.1.2", - "yaml": "^2.1.1", + "tmp": "^0.2.1", "yargs": "^17.5.1" } } diff --git a/src/commands/lint.ts b/src/commands/lint.ts index c78a599..f2bb0a6 100644 --- a/src/commands/lint.ts +++ b/src/commands/lint.ts @@ -3,11 +3,11 @@ import axios from "axios"; import type { Arguments } from "yargs"; const fs = require("fs"); +const tmp = require("tmp"); const homeDir = require("os").homedir(); const path = require("path"); const util = require("util"); const chalk = require("chalk"); -const YAML = require("yaml"); const Parsers = require("@stoplight/spectral-parsers"); const { fetch } = require("@stoplight/spectral-runtime"); const { Spectral, Document } = require("@stoplight/spectral-core"); @@ -34,37 +34,30 @@ interface Result { }; } -const rulesetUrl = "https://bw-linter-ruleset.s3.amazonaws.com/ruleset.yml"; -var rulesetFilename = ".remote.spectral.yaml"; -var rulesetFilepath = path.join(__dirname, rulesetFilename); +const tmpRulesetFile = tmp.fileSync({ postfix: ".js" }); +const RULESET_URL = "https://bw-linter-ruleset.s3.amazonaws.com/ruleset.js"; -async function downloadRuleset( - fileUrl: string, - outputLocationPath: string -): Promise { - const writer = fs.createWriteStream(outputLocationPath); - - return axios({ +const downloadRemoteRuleset = async () => { + const response = await axios({ method: "get", - url: fileUrl, - responseType: "stream", - }).then((response) => { - return new Promise((resolve, reject) => { - response.data.pipe(writer); - let error: any = null; - writer.on("error", (err: any) => { - error = err; - writer.close(); - reject(err); - }); - writer.on("close", () => { - if (!error) { - resolve(true); - } - }); - }); + url: RULESET_URL, }); -} + + // Check for failure response + if (response.status != 200) { + console.error(`Failed to retrieve ruleset from AWS.`); + return false; + } + + try { + fs.writeFileSync(tmpRulesetFile.name, response.data); + return true; + } catch (err) { + // If there is an issue writing, default to local + console.error(`Failed to write ruleset to file, error: ${err}`); + return false; + } +}; function saveResult(resultFilename: string, homeDir: string, result: Object) { try { @@ -77,14 +70,6 @@ function saveResult(resultFilename: string, homeDir: string, result: Object) { } } -function deleteRemoteRuleset() { - try { - fs.unlinkSync(path.join(__dirname, rulesetFilename)); - } catch (err) { - console.error(err); - } -} - exports.command = "lint "; exports.aliases = ["l"]; exports.describe = "Lint an OpenAPI Specification"; @@ -110,16 +95,16 @@ exports.handler = async (argv: Arguments): Promise => { // Open the provided API Spec const { specPath, save, json, ruleset } = argv; const specFile = fs.readFileSync(specPath, "utf8"); - const spec = YAML.parse(specFile); - var specName = path.basename(specPath, path.extname(specPath)); + let specName = path.basename(specPath, path.extname(specPath)); + let rulesetFilepath; - // attempt to download the ruleset if no local file was provided - var downloadSuccess; + // Attempt to download the ruleset if no local file was provided if (!ruleset) { - downloadSuccess = true; - try { - await downloadRuleset(rulesetUrl, rulesetFilepath); - } catch (error) { + const downloadSuccess = await downloadRemoteRuleset(); + if (downloadSuccess) { + // Use the downloaded ruleset + rulesetFilepath = tmpRulesetFile.name; + } else { // Error downloading the remote ruleset - use the bundled local copy console.warn( chalk.yellow.bold( @@ -127,12 +112,12 @@ exports.handler = async (argv: Arguments): Promise => { ) ); console.log("Note that lint results may vary from production ruleset."); - rulesetFilename = "./static/.local.spectral.yaml"; + const rulesetFilename = "./static/.local.ruleset.js"; rulesetFilepath = path.join(__dirname, "..", rulesetFilename); console.log(rulesetFilepath); - downloadSuccess = false; } } else { + // Use the provided ruleset rulesetFilepath = ruleset; } @@ -141,6 +126,7 @@ exports.handler = async (argv: Arguments): Promise => { spectral.setRuleset( await bundleAndLoadRuleset(rulesetFilepath, { fs, fetch }) ); + spectral.set; // Run the linter await spectral @@ -163,9 +149,4 @@ exports.handler = async (argv: Arguments): Promise => { saveResult(specName + "_lint_result.json", homeDir, result); } }); - - // If ruleset was downloaded successfully - delete it - if (downloadSuccess) { - deleteRemoteRuleset(); - } }; diff --git a/src/static/.local.ruleset.js b/src/static/.local.ruleset.js new file mode 100644 index 0000000..73a0e9c --- /dev/null +++ b/src/static/.local.ruleset.js @@ -0,0 +1,355 @@ +import { truthy, falsy, casing, pattern } from "@stoplight/spectral-functions"; +import { oas } from "@stoplight/spectral-rulesets"; + +// Custom Ruleset Functions +const validate5xx = (input) => { + // Compile a list of all the status codes defined for a response object + const statusCodeArray = []; + for (let x in input) { + statusCodeArray.push(x); + } + + // Check to see if one of the status codes in the array matches our regex pattern for 5XX errors + const match = statusCodeArray.find((value) => + /(5[0-9][0-9])|5XX/.test(value) + ); + + // If no match is found, return an error + if (!match) { + return [ + { + message: "Responses must document a 5XX error.", + }, + ]; + } +}; + +// Ruleset +export default { + extends: oas, + rules: { + openApiFields: { + description: "There should be components, security, and a tags object.", + message: "The spec is missing the '{{property}}' object.", + given: "$", + severity: "error", + then: [ + { + field: "security", + function: truthy, + }, + { + field: "tags", + function: truthy, + }, + { + field: "components", + function: truthy, + }, + ], + }, + infoFieldsRequired: { + description: + "The Info object must include a title, version, description, and termsOfService.", + message: "The Info object is missing the '{{property}}' property.", + given: "$.info", + severity: "error", + then: [ + { + field: "title", + function: truthy, + }, + { + field: "version", + function: truthy, + }, + { + field: "description", + function: truthy, + }, + { + field: "termsOfService", + function: truthy, + }, + ], + }, + validSemanticVersion: { + description: + "Verifies that the version is a valid semantic version in the format `x.x.x`.", + message: + "The spec should follow semantic versioning. '{{value}}' is not a valid version.", + severity: "error", + given: "$.info.version", + then: [ + { + function: pattern, + functionOptions: { + match: + "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + }, + }, + ], + }, + pathsSummary: { + description: "There needs to be a summary field for every method.", + message: "There needs to be a summary property for every method.", + given: "$.paths.*.*", + resolved: false, + severity: "error", + then: [ + { + field: "summary", + function: truthy, + }, + ], + }, + componentsFieldsRequired: { + description: + "All of the following components sections must exist - Schemas, Responses, and SecuritySchema.", + message: "Components is missing the '{{property}}' section.", + given: "$.components", + severity: "error", + then: [ + { + field: "schemas", + function: truthy, + }, + { + field: "responses", + function: truthy, + }, + { + field: "securitySchemes", + function: truthy, + }, + ], + }, + pathsParametersUsesGlobalReference: { + description: "There should be a parameters sections within components.", + message: + "{{property}} is not using a globally defined parameter. All parameters must be defined and referenced using the `$.components.parameters` section.", + given: "$.paths.[*][*].parameters[*]", + resolved: false, + severity: "warn", + then: [ + { + field: "$ref", + function: truthy, + }, + ], + }, + pathsRequestBodiesUsesGlobalReference: { + description: + "Request bodies for operations should be a reference a globally defined component.", + message: + "{{property}} is missing the '$ref' attribute. Global requestBody reference not properly implemented", + given: "$.paths.[*].[put,patch,post].requestBody", + severity: "warn", + resolved: false, + then: [ + { + field: "$ref", + function: truthy, + }, + ], + }, + pathsRequestBodiesCantDefineContent: { + description: + "Request bodies for operations should not define their own 'content' properties.", + message: + "Opertaion request bodies cannot define their own {{property}} properties - please define and refernece using the `$.components.requestBodues` section.", + given: "$.paths.[*].[put,patch,post].requestBody", + severity: "warn", + resolved: false, + then: [ + { + field: "content", + function: falsy, + }, + ], + }, + camelCaseOperationId: { + description: "All operationIds should be written in camelCase.", + message: "'{{value}}' needs to be written in camelCase.", + type: "style", + given: "$.paths.*.*.operationId", + severity: "error", + then: [ + { + function: casing, + functionOptions: { + type: "camel", + }, + }, + ], + }, + camelCaseComponents: { + description: + "All components (schemas, parameters, etc) should be named in camelCase.", + message: "'{{property}}' needs to be written in camelCase.", + type: "style", + given: + "$.components.[schemas,parameters,requestBodies,examples,responses].*~", + severity: "warn", + then: [ + { + function: casing, + functionOptions: { + type: "camel", + }, + }, + ], + }, + camelCaseComponentNames: { + description: "All component names should be written in camelCase.", + message: "'{{property}}' needs to be written in camelCase.", + type: "style", + given: + "$.components.[schemas,parameters,requestBodies,examples,responses].*.name", + severity: "warn", + then: [ + { + function: casing, + functionOptions: { + type: "camel", + }, + }, + ], + }, + macroCaseEnum: { + description: "Enums should be in MACRO_CASE.", + message: "Enum value '{{value}}' should be written in MACRO_CASE", + type: "style", + given: "$.components..enum.[*]", + severity: "warn", + then: [ + { + function: casing, + functionOptions: { + type: "macro", + }, + }, + ], + }, + required4xxResponses: { + description: + "Resources must declare a response for HTTP 400, 401, 403, 404, 405, and 429 errors.", + message: + "The resource is missing a definition for a '{{property}}' response.", + given: "$.paths..responses", + severity: "warn", + then: [ + { + field: "400", + function: truthy, + }, + { + field: "401", + function: truthy, + }, + { + field: "403", + function: truthy, + }, + { + field: "404", + function: truthy, + }, + { + field: "405", + function: truthy, + }, + { + field: "429", + function: truthy, + }, + ], + }, + internalServerError: { + description: + "Resources must declare a response for a HTTP 500 Internal Server error", + message: "{{error}}", + given: "$.paths..responses", + severity: "warn", + then: [ + { + function: validate5xx, + }, + ], + }, + globallyDefinedResponses: { + description: + "Responses should be defined globally and referenced elsewhere.", + message: + "All responses should be defined globally and only referenced everywhere else.", + given: '$.paths.[*].responses[?(!@path.includes("204"))]', + resolved: false, + severity: "error", + then: [ + { + field: "$ref", + function: truthy, + }, + ], + }, + serversUseHttps: { + description: "All servers should point to an HTTPS URL.", + message: "'{{value}}' is not an https url.", + severity: "warn", + given: [ + "$.servers..url", + "$.paths.[*].servers..url", + "$.paths.[*][*].servers..url", + ], + then: [ + { + function: pattern, + functionOptions: { + match: + "^https:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)", + }, + }, + ], + }, + noEmptyPath: { + description: "Paths should not contain double '/' characters.", + message: "'{{property}}' contains double `/` characters.", + severity: "error", + given: "$.paths", + then: { + field: "@key", + function: pattern, + functionOptions: { + notMatch: "(\\/){2,}", + }, + }, + }, + noEmptyPathInServer: { + description: "Servers should not contain double '/' after the protocol.", + message: "'{{value}}' contains double `/` characters.", + severity: "error", + given: [ + "$.servers..url", + "$.paths.[*].servers..url", + "$.paths.[*][*].servers..url", + ], + then: { + function: pattern, + functionOptions: { + notMatch: "(? { }; const testLintWithLocalRuleset = (args) => { - return execSync(`node build/cli.js lint ${args} -j -r ${__dirname + "/../src/static/.local.spectral.yaml"}`).toString(); + return execSync( + `node build/cli.js lint ${args} -j -r ${ + __dirname + "/../src/static/.local.ruleset.js" + }` + ).toString(); }; describe("cli", () => { @@ -52,7 +56,9 @@ describe("cli", () => { }); it("should run lint command using a spec with errors against a local ruleset file", async () => { - result = JSON.parse(testLintWithLocalRuleset("./tests/fixtures/testSpec.yaml")); + result = JSON.parse( + testLintWithLocalRuleset("./tests/fixtures/testSpec.yaml") + ); testObj = result[1]; expect(typeof testObj.code).toBe("string"); expect(typeof testObj.message).toBe("string");