From ebd6f81596dc8a41b75c088092c514e2561e83c7 Mon Sep 17 00:00:00 2001 From: David Ong <45852430+vvidday@users.noreply.github.com> Date: Tue, 21 Feb 2023 10:54:00 +0800 Subject: [PATCH] [#1756] Add JSON validation and migrate api.js to TypeScript (#1903) Currently, the objects produced by reading from summary.json, authorship.json and commits.json are not validated against any schema. This results in these objects having an implicit any type, which might lead to type errors during runtime. Let's add support for JSON validation with the zod library and migrate the file where the JSON files are parsed, api.js, to TypeScript. This will increase type safety and enable the migration of other files. --- frontend/package-lock.json | 5 ++ frontend/package.json | 3 +- frontend/src/types/types.ts | 47 +++++++++++++++ frontend/src/types/window.ts | 72 +++++++++++++++++++++++ frontend/src/types/zod/authorship-type.ts | 22 +++++++ frontend/src/types/zod/commits-type.ts | 36 ++++++++++++ frontend/src/types/zod/summary-type.ts | 53 +++++++++++++++++ frontend/src/utils/{api.js => api.ts} | 70 ++++++++++++---------- frontend/src/utils/user.ts | 11 ++-- 9 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 frontend/src/types/types.ts create mode 100644 frontend/src/types/window.ts create mode 100644 frontend/src/types/zod/authorship-type.ts create mode 100644 frontend/src/types/zod/commits-type.ts create mode 100644 frontend/src/types/zod/summary-type.ts rename frontend/src/utils/{api.js => api.ts} (83%) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b949063a4b..9901aad97c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12440,6 +12440,11 @@ "dev": true } } + }, + "zod": { + "version": "3.20.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.20.6.tgz", + "integrity": "sha512-oyu0m54SGCtzh6EClBVqDDlAYRz4jrVtKwQ7ZnsEmMI9HnzuZFj8QFwAY1M5uniIYACdGvv0PBWPF2kO0aNofA==" } } } diff --git a/frontend/package.json b/frontend/package.json index 6ee1e634b4..8e5cf19276 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,8 @@ "vue": "^3.2.47", "vue-loading-overlay": "^5.0.3", "vue-observe-visibility": "^1.0.0", - "vuex": "^4.0.2" + "vuex": "^4.0.2", + "zod": "^3.20.6" }, "devDependencies": { "@babel/eslint-parser": "^7.17.0", diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts new file mode 100644 index 0000000000..dc4417341a --- /dev/null +++ b/frontend/src/types/types.ts @@ -0,0 +1,47 @@ +import { FileResult } from './zod/authorship-type'; +import { + AuthorFileTypeContributions, + CommitResultRaw, + Commits, +} from './zod/commits-type'; +import { RepoRaw } from './zod/summary-type'; + +// We add these three fields in setContributionOfCommitResultsAndInsertRepoId of utils/api.ts +export interface CommitResult extends CommitResultRaw { + repoId: string; + insertions: number; + deletions: number; +} + +// Similar to AuthorDailyContributions, but uses the updated CommitResult with the three new fields +export interface DailyCommit { + commitResults: CommitResult[]; + date: string; +} + +// Similar to DailyCommit, but contains the total insertions and deletions for all CommitResults +export interface Commit extends DailyCommit { + deletions: number; + insertions: number; +} + +export interface User { + checkedFileTypeContribution: number; + commits?: Commit[]; + dailyCommits: DailyCommit[]; + displayName: string; + fileTypeContribution: AuthorFileTypeContributions; + location: string; + name: string; + repoId: string; + repoName: string; + searchPath: string; + variance: number; +} + +// We add these three fields in loadCommits and loadAuthorship of utils/api.ts +export interface Repo extends RepoRaw { + commits?: Commits; + files?: FileResult[]; + users?: User[]; +} diff --git a/frontend/src/types/window.ts b/frontend/src/types/window.ts new file mode 100644 index 0000000000..19946c4ae7 --- /dev/null +++ b/frontend/src/types/window.ts @@ -0,0 +1,72 @@ +import JSZip from 'jszip'; +import User from '../utils/user'; +import { Repo, User as UserType } from './types'; +import { AuthorshipSchema } from './zod/authorship-type'; +import { AuthorDailyContributions } from './zod/commits-type'; +import { DomainUrlMap, ErrorMessage } from './zod/summary-type'; + +// Declares the types for all the global variables under the window object +export {}; + +interface comparatorFunction { + (a: any, b: any): -1 | 0 | 1; +} + +interface sortingFunction { + (item: any, sortingOption?: string): any; +} + +interface api { + loadJSON: (fname: string) => Promise; + loadSummary: () => Promise<{ + creationDate: string, + reportGenerationTime: string, + errorMessages: { [key: string]: ErrorMessage }, + names: string[], + } | null>; + loadCommits: (repoName: string) => Promise; + loadAuthorship: (repoName: string) => Promise; + setContributionOfCommitResultsAndInsertRepoId: (dailyCommits: AuthorDailyContributions[], repoId: string) => void; +} + +declare global { + interface Window { + $: (id: string) => HTMLElement | null; + enquery: (key: string, val: string) => string; + REPOSENSE_REPO_URL: string; + HOME_PAGE_URL: string; + UNSUPPORTED_INDICATOR: string; + DAY_IN_MS: number; + HASH_DELIMITER: string; + REPOS: { [key: string]: Repo }; + hashParams: { [key: string]: string }; + isMacintosh: boolean; + REPORT_ZIP: JSZip | null; + deactivateAllOverlays: () => void; + getDateStr: (date: Date) => string; + getHexToRGB: (color: string) => number[]; + getFontColor: (color: string) => string; + addHash: (newKey: string, newVal: string) => void; + removeHash: (key: string) => void; + encodeHash: () => void; + decodeHash: () => void; + comparator: (fn: sortingFunction, sortingOption: string) => comparatorFunction; + filterUnsupported: (string: string) => string | undefined; + getAuthorLink: (repoId: string, author: string) => string | undefined; + getRepoLinkUnfiltered: (repoId: string) => string; + getRepoLink: (repoId: string) => string | undefined; + getBranchLink: (repoId: string, branch: string) => string | undefined; + getCommitLink: (repoId: string, commitHash: string) => string | undefined; + getBlameLink: (repoId: string, branch: string, filepath: string) => string | undefined; + getHistoryLink: (repoId: string, branch: string, filepath: string) => string | undefined; + getGroupName: (group: UserType[], filterGroupSelection: string) => string; + getAuthorDisplayName: (authorRepos: Repo[]) => string; + api: api; + sinceDate: string; + untilDate: string; + repoSenseVersion: string; + isSinceDateProvided: boolean; + isUntilDateProvided: boolean; + DOMAIN_URL_MAP: DomainUrlMap; + } +} diff --git a/frontend/src/types/zod/authorship-type.ts b/frontend/src/types/zod/authorship-type.ts new file mode 100644 index 0000000000..bf2dcd6cfb --- /dev/null +++ b/frontend/src/types/zod/authorship-type.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const lineSchema = z.object({ + lineNumber: z.number(), + author: z.object({ gitId: z.string() }), + content: z.string(), +}); + +const fileResult = z.object({ + path: z.string(), + fileType: z.string(), + lines: z.array(lineSchema), + authorContributionMap: z.record(z.number()), +}); + +// Contains the zod validation schema for the authorship.json file + +export const authorshipSchema = z.array(fileResult); + +// Export typescript types +export type AuthorshipSchema = z.infer; +export type FileResult = z.infer; diff --git a/frontend/src/types/zod/commits-type.ts b/frontend/src/types/zod/commits-type.ts new file mode 100644 index 0000000000..0d174d72a2 --- /dev/null +++ b/frontend/src/types/zod/commits-type.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const fileTypesAndContributionSchema = z.object({ + insertions: z.number(), + deletions: z.number(), +}); + +const commitResult = z.object({ + hash: z.string(), + messageTitle: z.string(), + messageBody: z.string(), + tags: z.array(z.string()).optional(), + fileTypesAndContributionMap: z.record(fileTypesAndContributionSchema), +}); + +const authorDailyContributionsSchema = z.object({ + date: z.string(), + commitResults: z.array(commitResult), +}); + +const authorFileTypeContributionsSchema = z.record(z.number()); + +// Contains the zod validation schema for the commits.json file + +export const commitsSchema = z.object({ + authorDailyContributionsMap: z.record(z.array(authorDailyContributionsSchema)), + authorFileTypeContributionMap: z.record(authorFileTypeContributionsSchema), + authorContributionVariance: z.record(z.number()), + authorDisplayNameMap: z.record(z.string()), +}); + +// Export typescript types +export type Commits = z.infer; +export type CommitResultRaw = z.infer; +export type AuthorDailyContributions = z.infer; +export type AuthorFileTypeContributions = z.infer; diff --git a/frontend/src/types/zod/summary-type.ts b/frontend/src/types/zod/summary-type.ts new file mode 100644 index 0000000000..a390bf4957 --- /dev/null +++ b/frontend/src/types/zod/summary-type.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +const locationSchema = z.object({ + location: z.string(), + repoName: z.string(), + organization: z.string(), + domainName: z.string(), +}); + +const repoSchema = z.object({ + location: locationSchema, + branch: z.string(), + displayName: z.string(), + outputFolderName: z.string(), +}); + +const errorSchema = z.object({ + repoName: z.string(), + errorMessage: z.string(), +}); + +const urlSchema = z.object({ + BASE_URL: z.string(), + BLAME_PATH: z.string(), + BRANCH: z.string(), + COMMIT_PATH: z.string(), + HISTORY_PATH: z.string(), + REPO_URL: z.string(), +}); + +const supportedDomainUrlMapSchema = z.record(urlSchema); + +// Contains the zod validation schema for the summary.json file + +export const summarySchema = z.object({ + repoSenseVersion: z.string(), + reportGeneratedTime: z.string(), + reportGenerationTime: z.string(), + zoneId: z.string(), + reportTitle: z.string(), + repos: z.array(repoSchema), + errorSet: z.array(errorSchema), + sinceDate: z.string(), + untilDate: z.string(), + isSinceDateProvided: z.boolean(), + isUntilDateProvided: z.boolean(), + supportedDomainUrlMap: supportedDomainUrlMapSchema, +}); + +// Export typescript types +export type DomainUrlMap = z.infer; +export type RepoRaw = z.infer; +export type ErrorMessage = z.infer; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.ts similarity index 83% rename from frontend/src/utils/api.js rename to frontend/src/utils/api.ts index b470f0cf7e..57271e0876 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.ts @@ -1,3 +1,7 @@ +import { DailyCommit, CommitResult } from '../types/types'; +import { authorshipSchema } from '../types/zod/authorship-type'; +import { commitsSchema } from '../types/zod/commits-type'; +import { ErrorMessage, summarySchema } from '../types/zod/summary-type'; import User from './user'; // utility functions // @@ -30,7 +34,7 @@ window.getDateStr = function getDateStr(date) { window.getHexToRGB = function getHexToRGB(color) { // to convert color from hex code to rgb format const arr = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); - return arr.slice(1).map((val) => parseInt(val, 16)); + return arr ? arr.slice(1).map((val) => parseInt(val, 16)) : []; }; window.getFontColor = function getFontColor(color) { @@ -64,7 +68,7 @@ window.encodeHash = function encodeHash() { }; window.decodeHash = function decodeHash() { - const hashParams = {}; + const hashParams: { [key: string]: string } = {}; const hashIndex = window.location.href.indexOf(HASH_ANCHOR); const parameterString = hashIndex === -1 ? '' : window.location.href.slice(hashIndex + 1); @@ -76,8 +80,8 @@ window.decodeHash = function decodeHash() { try { hashParams[key] = decodeURIComponent(val); } catch (error) { - this.userUpdated = false; - this.isLoading = false; + (this as any).userUpdated = false; + (this as any).isLoading = false; } } }); @@ -199,10 +203,11 @@ window.api = { }, async loadSummary() { window.REPOS = {}; - let data = {}; + let data; try { - data = await this.loadJSON('summary.json'); - } catch (error) { + const json = await this.loadJSON('summary.json'); + data = summarySchema.parse(json); + } catch (error: any) { if (error.message === 'Unable to read summary.json.') { return null; } @@ -216,14 +221,14 @@ window.api = { window.isUntilDateProvided = data.isUntilDateProvided; document.title = data.reportTitle || document.title; - const errorMessages = {}; + const errorMessages: { [key: string]: ErrorMessage } = {}; Object.entries(data.errorSet).forEach(([repoName, message]) => { errorMessages[repoName] = message; }); window.DOMAIN_URL_MAP = data.supportedDomainUrlMap; - const names = []; + const names: string[] = []; data.repos.forEach((repo) => { const repoName = `${repo.displayName}`; window.REPOS[repoName] = repo; @@ -239,33 +244,37 @@ window.api = { async loadCommits(repoName) { const folderName = window.REPOS[repoName].outputFolderName; - const commits = await this.loadJSON(`${folderName}/commits.json`); - const res = []; + const json = await this.loadJSON(`${folderName}/commits.json`); + const commits = commitsSchema.parse(json); + + const res: User[] = []; const repo = window.REPOS[repoName]; Object.keys(commits.authorDisplayNameMap).forEach((author) => { if (author) { - const obj = { - name: author, - repoId: repoName, - variance: commits.authorContributionVariance[author], - displayName: commits.authorDisplayNameMap[author], - dailyCommits: commits.authorDailyContributionsMap[author], - fileTypeContribution: commits.authorFileTypeContributionMap[author], - }; - - this.setContributionOfCommitResultsAndInsertRepoId(obj.dailyCommits, obj.repoId); + this.setContributionOfCommitResultsAndInsertRepoId(commits.authorDailyContributionsMap[author], repoName); const searchParams = [ repo.displayName, - obj.displayName, author, + commits.authorDisplayNameMap[author], + author, ]; - obj.searchPath = searchParams.join('_').toLowerCase(); - obj.repoName = `${repo.displayName}`; - obj.location = `${repo.location.location}`; + // commits and checkedFileTypeContribution are set in c-summary + const user = new User({ + name: author, + repoId: repoName, + variance: commits.authorContributionVariance[author], + displayName: commits.authorDisplayNameMap[author], + commits: [], + dailyCommits: commits.authorDailyContributionsMap[author] as DailyCommit[], + fileTypeContribution: commits.authorFileTypeContributionMap[author], + searchPath: searchParams.join('_').toLowerCase(), + repoName: `${repo.displayName}`, + location: `${repo.location.location}`, + checkedFileTypeContribution: 0, + }); - const user = new User(obj); res.push(user); } }); @@ -279,7 +288,8 @@ window.api = { loadAuthorship(repoName) { const folderName = window.REPOS[repoName].outputFolderName; return this.loadJSON(`${folderName}/authorship.json`) - .then((files) => { + .then((json) => { + const files = authorshipSchema.parse(json); window.REPOS[repoName].files = files; return files; }); @@ -290,10 +300,10 @@ window.api = { setContributionOfCommitResultsAndInsertRepoId(dailyCommits, repoId) { dailyCommits.forEach((commit) => { commit.commitResults.forEach((result) => { - result.repoId = repoId; - result.insertions = Object.values(result.fileTypesAndContributionMap) + (result as CommitResult).repoId = repoId; + (result as CommitResult).insertions = Object.values(result.fileTypesAndContributionMap) .reduce((acc, fileType) => acc + fileType.insertions, 0); - result.deletions = Object.values(result.fileTypesAndContributionMap) + (result as CommitResult).deletions = Object.values(result.fileTypesAndContributionMap) .reduce((acc, fileType) => acc + fileType.deletions, 0); }); }); diff --git a/frontend/src/utils/user.ts b/frontend/src/utils/user.ts index aa755fda53..08fada6e72 100644 --- a/frontend/src/utils/user.ts +++ b/frontend/src/utils/user.ts @@ -1,13 +1,16 @@ -export default class User { +import { Commit, DailyCommit, User as UserType } from '../types/types'; +import { AuthorFileTypeContributions } from '../types/zod/commits-type'; + +export default class User implements UserType { checkedFileTypeContribution : number; - commits: Array; + commits: Commit[]; - dailyCommits: Array; + dailyCommits: DailyCommit[]; displayName: string; - fileTypeContribution: object; + fileTypeContribution: AuthorFileTypeContributions; location: string;