Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(tools): script to bump openapi spec dependency versions #2340

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"enable-corepack": "npm i -g corepack && corepack enable && corepack prepare yarn@1.22.17 --activate",
"custom-checks": "TS_NODE_PROJECT=./tools/tsconfig.json node --trace-deprecation --experimental-modules --abort-on-uncaught-exception --loader ts-node/esm --experimental-specifier-resolution=node ./tools/custom-checks/run-custom-checks.ts",
"tools:validate-bundle-names": "TS_NODE_PROJECT=./tools/tsconfig.json node --trace-deprecation --experimental-modules --abort-on-uncaught-exception --loader ts-node/esm --experimental-specifier-resolution=node ./tools/validate-bundle-names.js",
"tools:bump-openapi-spec-dep-versions": "TS_NODE_PROJECT=./tools/tsconfig.json node --trace-deprecation --experimental-modules --abort-on-uncaught-exception --loader ts-node/esm --experimental-specifier-resolution=node ./tools/bump-openapi-spec-dep-versions.ts",
"tools:get-latest-sem-ver-git-tag": "TS_NODE_PROJECT=./tools/tsconfig.json node --abort-on-uncaught-exception --loader ts-node/esm --experimental-specifier-resolution=node --no-warnings ./tools/get-latest-sem-ver-git-tag.ts",
"generate-api-server-config": "node ./tools/generate-api-server-config.js",
"sync-ts-config": "TS_NODE_PROJECT=tools/tsconfig.json node --experimental-json-modules --loader ts-node/esm ./tools/sync-npm-deps-to-tsc-projects.ts",
"start:api-server": "node ./packages/cactus-cmd-api-server/dist/lib/main/typescript/cmd/cactus-api.js --config-file=.config.json",
Expand Down Expand Up @@ -79,7 +81,7 @@
"test:integration": "tap --ts --node-arg=--max-old-space-size=4096 --jobs=1 --timeout=3600 --no-check-coverage \"packages/cactus-*/src/test/typescript/integration/\"",
"changelog": "conventional-changelog --infile CHANGELOG.md --outfile CHANGELOG.md && git add CHANGELOG.md",
"commit": "git-cz --signoff",
"prettier": "prettier --write --config .prettierrc.json \"./**/*.{ts,js}\"",
"prettier": "prettier --write --config .prettierrc.js \"./**/src/main/json/openapi.json\"",
"version": "npm ci && npm run build:dev && npm run build:prod && npm run test:unit",
"lerna-publish-canary": "npm run run-ci && lerna publish --canary --force-publish --dist-tag $(git branch --show-current) --preid $(git branch --show-current).$(git rev-parse --short HEAD)",
"lerna-publish": "lerna publish --conventional-commits --sign-git-commit --sign-git-tag",
Expand All @@ -89,11 +91,11 @@
"devDependencies": {
"@commitlint/cli": "13.1.0",
"@commitlint/config-conventional": "13.1.0",
"@openapitools/openapi-generator-cli": "2.4.14",
"@lerna-lite/cli": "1.17.0",
"@lerna-lite/exec": "1.17.0",
"@lerna-lite/list": "1.17.0",
"@lerna-lite/run": "1.17.0",
"@openapitools/openapi-generator-cli": "2.4.14",
"@types/fs-extra": "9.0.12",
"@types/jasminewd2": "2.0.10",
"@types/jest": "27.5.0",
Expand All @@ -102,6 +104,7 @@
"@types/tape": "4.13.2",
"@types/tape-promise": "4.0.1",
"@types/uuid": "8.3.1",
"@types/yargs": "17.0.24",
"@typescript-eslint/eslint-plugin": "5.27.0",
"@typescript-eslint/parser": "5.27.0",
"buffer": "6.0.3",
Expand All @@ -121,12 +124,13 @@
"eslint-plugin-prettier": "3.4.0",
"eslint-plugin-promise": "5.1.0",
"eslint-plugin-standard": "5.0.0",
"fast-safe-stringify": "2.1.1",
"fs-extra": "10.0.0",
"git-cz": "4.7.6",
"globby": "12.0.0",
"google-protobuf": "3.21.2",
"grpc_tools_node_protoc_ts": "5.3.1",
"grpc-tools": "1.11.2",
"grpc_tools_node_protoc_ts": "5.3.1",
"husky": "7.0.1",
"inquirer": "8.1.2",
"jest": "28.1.0",
Expand All @@ -142,11 +146,14 @@
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"npm-watch": "0.11.0",
"openapi-types": "12.1.3",
"prettier": "2.1.2",
"protoc-gen-ts": "0.6.0",
"run-time-error": "1.4.0",
"secp256k1": "4.0.2",
"semver-parser": "4.1.4",
"shebang-loader": "0.0.1",
"simple-git": "3.19.1",
"sort-package-json": "1.53.1",
"source-map-loader": "3.0.0",
"stream-browserify": "3.0.0",
Expand All @@ -160,7 +167,8 @@
"webpack": "5.76.0",
"webpack-bundle-analyzer": "4.4.2",
"webpack-cli": "4.7.2",
"wget-improved": "3.4.0"
"wget-improved": "3.4.0",
"yargs": "17.7.2"
},
"resolutions": {
"ansi-html": ">0.0.8",
Expand Down
314 changes: 314 additions & 0 deletions tools/bump-openapi-spec-dep-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import { URL } from "url";
import { fileURLToPath } from "url";
import path from "path";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import fs from "fs-extra";
import { globby, Options as GlobbyOptions } from "globby";
import { RuntimeError } from "run-time-error";
import prettier from "prettier";
import { OpenAPIV3_1 } from "openapi-types";
import { isValidSemVer } from "semver-parser";
import fastSafeStringify from "fast-safe-stringify";

import { hasKey } from "./has-key";
import { getLatestSemVerGitTagV1 } from "./get-latest-sem-ver-git-tag";

const TAG = "[tools/bump-openapi-spec-dep-versions.ts]";
export interface IBumpOpenAPISpecDepVersionsV1Request {
readonly argv: string[];
readonly env: NodeJS.ProcessEnv;
readonly targetVersion: string;
}

export interface IBumpOpenAPISpecDepVersionsV1Response {
readonly specFilePaths: string[];
readonly specFileReports: ISpecFileReportV1[];
}

export interface ISpecFileReportV1 {
readonly specFilePath: string;
readonly replacementCount: number;
readonly replacements: ISpecRefReplacementV1[];
}

export interface ISpecRefReplacementV1 {
readonly propertyPath: string;
readonly oldValue: string;
readonly newValue: string;
}

const nodePath = path.resolve(process.argv[1]);
const modulePath = path.resolve(fileURLToPath(import.meta.url));
const isRunningDirectlyViaCLI = nodePath === modulePath;

const main = async (argv: string[], env: NodeJS.ProcessEnv) => {
const req = await createRequest(argv, env);
await bumpOpenApiSpecDepVersions(req);
};

if (isRunningDirectlyViaCLI) {
main(process.argv, process.env);
}

async function createRequest(
argv: string[],
env: NodeJS.ProcessEnv,
): Promise<IBumpOpenAPISpecDepVersionsV1Request> {
if (!argv) {
throw new RuntimeError(`Process argv cannot be falsy.`);
}
if (!env) {
throw new RuntimeError(`Process env cannot be falsy.`);
}

const { latestSemVerTag } = await getLatestSemVerGitTagV1({
// We have to exclude "v2.0.0-alpha-prerelease" because it was not named
// according to the specs and is considered newer by the parser than
// alpha.1 but we actually issued alpha.1 later
excludedTags: ["v2.0.0-alpha-prerelease"],
omitFetch: false,
});

const optDescTargetVersion =
"The version to bump to, such as 1.0.0 or 2.0.0-alpha.1, etc. Defaults " +
"to the current latest version tag found in git where a version tag is " +
"defined as anything **starting with** the character v and then " +
"containing 3 numbers that are separated by dots";

const parsedCfg = await yargs(hideBin(argv))
.env("CACTI_")
.option("target-version", {
alias: "v",
type: "string",
description: optDescTargetVersion,
defaultDescription: "Defaults to the latest release git tag (vX.Y.Z.*",
default: latestSemVerTag,
}).argv;

// These explicit casts are safe because we provided the coercion functions
// for both parameters.
const targetVersion = (await parsedCfg.targetVersion) as string;

const req: IBumpOpenAPISpecDepVersionsV1Request = {
argv,
env,
targetVersion,
};

return req;
}

function traversePojoRefs(
file: string,
newVersion: string,
pojo: unknown,
replacements: ISpecRefReplacementV1[],
propPartPaths: string[],
): ISpecRefReplacementV1[] {
if (!pojo || typeof pojo !== "object") {
throw new RuntimeError(`Expected "pojo" as a Plain Old Javascript Object`);
}
Object.entries(pojo).forEach(([key, oldVal]: [string, unknown]) => {
if (!oldVal) {
return;
} else if (typeof oldVal === "object") {
propPartPaths.push(key);
traversePojoRefs(file, newVersion, oldVal, replacements, propPartPaths);
propPartPaths.pop();
} else if (key === "$ref") {
if (typeof oldVal !== "string") {
throw new RuntimeError(`Expected string value for $ref in ${file}`);
}
if (oldVal.startsWith("#")) {
// skip references that are local, e.g. pointint go a schema component
// within the same openapi.json specification file because these are
// not going to have a git tag in their URLs (because they aren't URLs)
return;
}
if (!hasKey(pojo, "$ref")) {
throw new RuntimeError(`Expected pojo to have a "$pref" property.`);
}

const aUrl = tryParseUrl(oldVal);
const urlPathParts = aUrl.pathname.split("/");

let dirty = false;

urlPathParts.forEach((x, idx) => {
if (isValidSemVer(x) && x !== newVersion) {
dirty = true;
urlPathParts[idx] = newVersion;
console.log(`${TAG} ${file} Swapping "${x}" to "${newVersion}"`);
}
});

if (dirty) {
aUrl.pathname = urlPathParts.join("/");
const newValue = aUrl.toString();
console.log(`${TAG} ${file} Swapping "${oldVal}" to "${newValue}"`);
pojo[key] = newValue;
const propertyPath = ".".concat(propPartPaths.join("."));
replacements.push({
newValue,
oldValue: oldVal,
propertyPath,
});
}
}
});
return replacements;
}

async function bumpOpenApiSpecDepVersionsOneFile(
filePathAbs: string,
filePathRel: string,
newVersion: string,
): Promise<ISpecRefReplacementV1[]> {
const openApiJson: OpenAPIV3_1.Document = await fs.readJSON(filePathAbs);
if (!openApiJson) {
throw new RuntimeError(`Expected ${filePathRel} to be truthy.`);
}
if (typeof openApiJson !== "object") {
throw new RuntimeError(`Expected ${filePathRel} to be an object`);
}
if (!openApiJson.info) {
openApiJson.info = { title: filePathRel, version: "0.0.0" };
}

const replacements = traversePojoRefs(
filePathRel,
newVersion,
openApiJson,
[],
[],
);

if (openApiJson.info.version !== newVersion) {
const oldVersion = openApiJson.info.version;
openApiJson.info.version = newVersion;

console.log(`${TAG} Bumped to ${newVersion} in ${filePathRel}`);

replacements.push({
newValue: newVersion,
oldValue: oldVersion,
propertyPath: ".info.version",
});
}

// We have to format the JSON string first in order to make it consistent
// with the input that the CLI invocations of prettier receive, otherwise
// the end result of the library call here and the CLI call there can vary.
const specAsJsonString = JSON.stringify(openApiJson, null, 2);

// Format the updated JSON object
const prettierCfg = await prettier.resolveConfig(".prettierrc.js");
if (!prettierCfg) {
throw new RuntimeError(`Could not locate .prettierrc.js in project dir`);
}
const prettierOpts = { ...prettierCfg, parser: "json" };
const prettyJson = prettier.format(specAsJsonString, prettierOpts);

if (replacements.length > 0) {
console.log(`${TAG} writing changes to disk or ${filePathRel}`);
await fs.writeFile(filePathAbs, prettyJson);
}
return replacements;
}

function tryParseUrl(x: unknown): URL {
if (typeof x !== "string") {
throw new RuntimeError(`${TAG} tryParseUrl() expected string input.`);
}
try {
return new URL(x);
} catch (ex) {
console.error(`${TAG} parsing failed for ${x}`, ex);
let innerEx;
if (ex instanceof Error || typeof ex === "string") {
innerEx = ex;
} else {
innerEx = fastSafeStringify(ex);
}
throw new RuntimeError(`${TAG} parsing failed for ${x}`, innerEx);
}
}

export async function bumpOpenApiSpecDepVersions(
req: IBumpOpenAPISpecDepVersionsV1Request,
): Promise<IBumpOpenAPISpecDepVersionsV1Response> {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SCRIPT_DIR = __dirname;
const PROJECT_DIR = path.join(SCRIPT_DIR, "../");
console.log(`${TAG} SCRIPT_DIR: ${SCRIPT_DIR}`);
console.log(`${TAG} PROJECT_DIR: ${PROJECT_DIR}`);

if (!req) {
throw new RuntimeError(`req parameter cannot be falsy.`);
}
if (!req.argv) {
throw new RuntimeError(`req.argv cannot be falsy.`);
}
if (!req.env) {
throw new RuntimeError(`req.env cannot be falsy.`);
}

const globbyOpts: GlobbyOptions = {
cwd: PROJECT_DIR,
ignore: ["**/node_modules"],
};

const DEFAULT_GLOB = "**/src/main/json/openapi.json";

const oasPaths = await globby(DEFAULT_GLOB, globbyOpts);

console.log(`${TAG} Looking up openapi.json spec files: ${DEFAULT_GLOB}`);
console.log(`${TAG} Detected ${oasPaths.length} openapi.json spec files`);
console.log(`${TAG} Expected Target Version: ${req.targetVersion}`);
console.log(`${TAG} File paths found:`, JSON.stringify(oasPaths, null, 4));

let replacementCountTotal = 0;
const specFileReportPromises = oasPaths.map(async (pathRel) => {
const filePathAbs = path.join(PROJECT_DIR, pathRel);

const replacements = await bumpOpenApiSpecDepVersionsOneFile(
filePathAbs,
pathRel,
req.targetVersion,
);

const specFileReport: ISpecFileReportV1 = {
replacementCount: replacements.length,
replacements,
specFilePath: pathRel,
};

replacementCountTotal += specFileReport.replacementCount;

return specFileReport;
});

const specFileReports = await Promise.all(specFileReportPromises);
const report = {
replacementCountTotal,
specFilePaths: oasPaths,
specFileReports,
};

const reportJson = JSON.stringify(report, null, 4);

const rootDistDirPath = path.join(PROJECT_DIR, "./build/");
await fs.mkdirp(rootDistDirPath);

const dateAndTime = new Date().toJSON().slice(0, 24).replaceAll(":", "-");
const filename = `cacti_bump-openapi-spec-dep-versions_${dateAndTime}.json`;
const specFileReportPathAbs = path.join(PROJECT_DIR, "./build/", filename);

console.log(`${TAG} Total number of replacements: ${replacementCountTotal}`);
console.log(`${TAG} Saving final report to: ${specFileReportPathAbs}`);
await fs.writeFile(specFileReportPathAbs, reportJson);

return report;
}
Loading