Skip to content

Commit

Permalink
chore(tools): script to bump openapi spec dependency versions
Browse files Browse the repository at this point in the history
1. Adds a new script that can be executed in order to bump all release
versions of the open API specs. These are one of two main categories:
1.1. The top level .info.version property of the OpenAPI spec JSON object
1.2. Any schema component references embedded anywhere deep inside the
JSON object that we define our specs as, for example when a connector
plugin's endpoint returns a response object whose schema is defined in
another package's OpenAPI spec file such as the core-api package's.
2. Fixed a bug in the "prettier" npm script which was pointing to a non-
existent configuration file. This was necessary to be fixed in this commit
because tests had to be carried out whether the auto-formatting applied
by the version bumping script is consistent with the auto-formatting that
is applied when we run prettier via the CLI (there was some struggle here
to figure out that we have to pre-format the JSON string held in-memory
via JSON.stringify() first otherwise the formatted version would diverge
from what the CLI does to the .json files on-disk)
3. We had to exclude the tag called v2.0.0-alpha-prerelease because it
was incorrectly named unfortunately: it is considered newer than
v2.0.0-alpha.1 by the semver-parser package but it is actually older,
e.g. we've issued v2.0.0-alpha-prerelease before we issued v2.0.0-alpha.1
(which is our mistake and this is the workaround to rectify that after
the fact without having to force delete the tag that we've already
pushed to the upstream main branch).
This exclusion is applied by default which might turn out to be a mistake
later but right now it does not look like there would be an edge case
where we need to handle this in any different way.
4. The script is re-entrant and will save a thorough report of what it did
to temporary .json files whose names are date and timestamped within the
project root's ./build/ subfolder.
5. The target version to bump to can be specified explicitly via the
--target-version CLI parameter but if it not provided then will default
to whatever is the latest sem-ver compatibly named tag in the git log.

Example to specify target version explicitly:
`yarn tools:bump-openapi-spec-dep-versions --target-version=v3.2.1`

Example to let the script figure out what the target version should be:
`yarn tools:bump-openapi-spec-dep-versions`

Fixes #2206

Co-authored-by: Peter Somogyvari <peter.somogyvari@accenture.com>

Signed-off-by: aldousalvarez <aldousss.alvarez@gmail.com>
Signed-off-by: Peter Somogyvari <peter.somogyvari@accenture.com>
  • Loading branch information
aldousalvarez authored and petermetz committed Jul 12, 2023
1 parent 6a4ecf1 commit 15ab85f
Show file tree
Hide file tree
Showing 5 changed files with 447 additions and 19 deletions.
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

0 comments on commit 15ab85f

Please sign in to comment.