Skip to content

Commit 3510ea9

Browse files
Mohit PaddhariyaMiodec
andauthored
refactor: replace JSON.parse with parseJsonWithSchema (@dev-mohit06) (monkeytypegame#6207)
## Description Replaces raw JSON parsing with schema-based validation across frontend TypeScript files to improve type safety and error handling. ### Scope of Changes - Updated JSON parsing in: - `account.ts` - `import-export-settings.ts` - `analytics-controller.ts` - `local-storage-with-schema.ts` - `url-handler.ts` - `commandline/lists.ts` - `test/wikipedia.ts` - Added schema in `test/custom-text.ts`: ```typescript export const customTextDataSchema = z.object({ text: z.array(z.string()), mode: CustomTextModeSchema, limit: z.object({ value: z.number(), mode: CustomTextLimitModeSchema }), pipeDelimiter: z.boolean(), }); ``` ### Benefits - Enhanced runtime type safety - More robust error handling - Consistent JSON parsing approach ### Checks - [x] Follows Conventional Commits - [x] Includes GitHub username - [ ] Adding quotes? (N/A) - [ ] Adding language/theme? (N/A) --------- Co-authored-by: Miodec <[email protected]>
1 parent 86cb17b commit 3510ea9

File tree

8 files changed

+92
-35
lines changed

8 files changed

+92
-35
lines changed

frontend/src/ts/commandline/lists.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ import * as FPSCounter from "../elements/fps-counter";
108108
import { migrateConfig } from "../utils/config";
109109
import { PartialConfigSchema } from "@monkeytype/contracts/schemas/configs";
110110
import { Command, CommandsSubgroup } from "./types";
111+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
111112

112113
const layoutsPromise = JSONData.getLayoutsList();
113114
layoutsPromise
@@ -362,8 +363,9 @@ export const commands: CommandsSubgroup = {
362363
exec: async ({ input }): Promise<void> => {
363364
if (input === undefined || input === "") return;
364365
try {
365-
const parsedConfig = PartialConfigSchema.strip().parse(
366-
JSON.parse(input)
366+
const parsedConfig = parseJsonWithSchema(
367+
input,
368+
PartialConfigSchema.strip()
367369
);
368370
await UpdateConfig.apply(migrateConfig(parsedConfig));
369371
UpdateConfig.saveFullConfigToLocalStorage();

frontend/src/ts/controllers/analytics-controller.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import {
66
} from "firebase/analytics";
77
import { app as firebaseApp } from "../firebase";
88
import { createErrorMessage } from "../utils/misc";
9+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
10+
import { z } from "zod";
911

1012
let analytics: AnalyticsType;
1113

12-
type AcceptedCookies = {
13-
security: boolean;
14-
analytics: boolean;
15-
};
14+
const AcceptedCookiesSchema = z.object({
15+
security: z.boolean(),
16+
analytics: z.boolean(),
17+
});
18+
19+
type AcceptedCookies = z.infer<typeof AcceptedCookiesSchema>;
1620

1721
export async function log(
1822
eventName: string,
@@ -26,9 +30,14 @@ export async function log(
2630
}
2731

2832
const lsString = localStorage.getItem("acceptedCookies");
29-
let acceptedCookies;
33+
let acceptedCookies: AcceptedCookies | null;
3034
if (lsString !== undefined && lsString !== null && lsString !== "") {
31-
acceptedCookies = JSON.parse(lsString) as AcceptedCookies;
35+
try {
36+
acceptedCookies = parseJsonWithSchema(lsString, AcceptedCookiesSchema);
37+
} catch (e) {
38+
console.error("Failed to parse accepted cookies:", e);
39+
acceptedCookies = null;
40+
}
3241
} else {
3342
acceptedCookies = null;
3443
}

frontend/src/ts/event-handlers/account.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { isAuthenticated } from "../firebase";
55
import * as Notifications from "../elements/notifications";
66
import * as EditResultTagsModal from "../modals/edit-result-tags";
77
import * as AddFilterPresetModal from "../modals/new-filter-preset";
8+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
9+
import { z } from "zod";
810

911
const accountPage = document.querySelector("#pageAccount") as HTMLElement;
1012

@@ -36,12 +38,15 @@ $(accountPage).on("click", ".editProfileButton", () => {
3638
EditProfileModal.show();
3739
});
3840

41+
const TagsArraySchema = z.array(z.string());
42+
3943
$(accountPage).on("click", ".group.history .resultEditTagsButton", (e) => {
4044
const resultid = $(e.target).attr("data-result-id");
4145
const tags = $(e.target).attr("data-tags");
46+
4247
EditResultTagsModal.show(
4348
resultid ?? "",
44-
JSON.parse(tags ?? "[]") as string[],
49+
parseJsonWithSchema(tags ?? "[]", TagsArraySchema),
4550
"accountPage"
4651
);
4752
});

frontend/src/ts/modals/import-export-settings.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as UpdateConfig from "../config";
33
import * as Notifications from "../elements/notifications";
44
import AnimatedModal from "../utils/animated-modal";
55
import { migrateConfig } from "../utils/config";
6+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
67

78
type State = {
89
mode: "import" | "export";
@@ -47,8 +48,9 @@ const modal = new AnimatedModal({
4748
return;
4849
}
4950
try {
50-
const parsedConfig = PartialConfigSchema.strip().parse(
51-
JSON.parse(state.value)
51+
const parsedConfig = parseJsonWithSchema(
52+
state.value,
53+
PartialConfigSchema.strip()
5254
);
5355
await UpdateConfig.apply(migrateConfig(parsedConfig));
5456
} catch (e) {

frontend/src/ts/test/custom-text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const customTextLongLS = new LocalStorageWithSchema({
2929
fallback: {},
3030
});
3131

32-
const CustomTextSettingsSchema = z.object({
32+
export const CustomTextSettingsSchema = z.object({
3333
text: z.array(z.string()),
3434
mode: CustomTextModeSchema,
3535
limit: z.object({ value: z.number(), mode: CustomTextLimitModeSchema }),

frontend/src/ts/test/wikipedia.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as Loader from "../elements/loader";
22
import * as Misc from "../utils/misc";
33
import * as Strings from "../utils/strings";
44
import * as JSONData from "../utils/json-data";
5+
import { z } from "zod";
6+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
57

68
export async function getTLD(
79
languageGroup: JSONData.LanguageGroup
@@ -241,6 +243,18 @@ type SectionObject = {
241243
author: string;
242244
};
243245

246+
// Section Schema
247+
const SectionSchema = z.object({
248+
query: z.object({
249+
pages: z.record(
250+
z.string(),
251+
z.object({
252+
extract: z.string(),
253+
})
254+
),
255+
}),
256+
});
257+
244258
export async function getSection(language: string): Promise<JSONData.Section> {
245259
// console.log("Getting section");
246260
Loader.show();
@@ -285,10 +299,17 @@ export async function getSection(language: string): Promise<JSONData.Section> {
285299
sectionReq.onload = (): void => {
286300
if (sectionReq.readyState === 4) {
287301
if (sectionReq.status === 200) {
288-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
289-
let sectionText = JSON.parse(sectionReq.responseText).query.pages[
290-
pageid.toString()
291-
].extract as string;
302+
const parsedResponse = parseJsonWithSchema(
303+
sectionReq.responseText,
304+
SectionSchema
305+
);
306+
const page = parsedResponse.query.pages[pageid.toString()];
307+
if (!page) {
308+
Loader.hide();
309+
rej("Page not found");
310+
return;
311+
}
312+
let sectionText = page.extract;
292313

293314
// Converting to one paragraph
294315
sectionText = sectionText.replace(/<\/p><p>+/g, " ");

frontend/src/ts/utils/url-handler.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@ import * as Loader from "../elements/loader";
1111
import * as AccountButton from "../elements/account-button";
1212
import { restart as restartTest } from "../test/test-logic";
1313
import * as ChallengeController from "../controllers/challenge-controller";
14-
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
14+
import {
15+
DifficultySchema,
16+
Mode2Schema,
17+
ModeSchema,
18+
} from "@monkeytype/contracts/schemas/shared";
1519
import {
1620
CustomBackgroundFilter,
1721
CustomBackgroundFilterSchema,
1822
CustomBackgroundSize,
1923
CustomBackgroundSizeSchema,
2024
CustomThemeColors,
2125
CustomThemeColorsSchema,
22-
Difficulty,
2326
} from "@monkeytype/contracts/schemas/configs";
2427
import { z } from "zod";
2528
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
@@ -129,24 +132,35 @@ export function loadCustomThemeFromUrl(getOverride?: string): void {
129132
}
130133
}
131134

132-
type SharedTestSettings = [
133-
Mode | null,
134-
Mode2<Mode> | null,
135-
CustomText.CustomTextData | null,
136-
boolean | null,
137-
boolean | null,
138-
string | null,
139-
Difficulty | null,
140-
string | null
141-
];
135+
const TestSettingsSchema = z.tuple([
136+
ModeSchema.nullable(),
137+
Mode2Schema.nullable(),
138+
CustomText.CustomTextSettingsSchema.nullable(),
139+
z.boolean().nullable(), //punctuation
140+
z.boolean().nullable(), //numbers
141+
z.string().nullable(), //language
142+
DifficultySchema.nullable(),
143+
z.string().nullable(), //funbox
144+
]);
145+
146+
type SharedTestSettings = z.infer<typeof TestSettingsSchema>;
142147

143148
export function loadTestSettingsFromUrl(getOverride?: string): void {
144149
const getValue = Misc.findGetParameter("testSettings", getOverride);
145150
if (getValue === null) return;
146151

147-
const de = JSON.parse(
148-
decompressFromURI(getValue) ?? ""
149-
) as SharedTestSettings;
152+
let de: SharedTestSettings;
153+
try {
154+
const decompressed = decompressFromURI(getValue) ?? "";
155+
de = parseJsonWithSchema(decompressed, TestSettingsSchema);
156+
} catch (e) {
157+
console.error("Failed to parse test settings:", e);
158+
Notifications.add(
159+
"Failed to load test settings from URL: " + (e as Error).message,
160+
0
161+
);
162+
return;
163+
}
150164

151165
const applied: Record<string, string> = {};
152166

packages/util/src/json.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import { ZodError, ZodIssue, ZodSchema } from "zod";
1+
import { z, ZodError, ZodIssue } from "zod";
22

33
/**
44
* Parse a JSON string into an object and validate it against a schema
55
* @param json JSON string
66
* @param schema Zod schema to validate the JSON against
77
* @returns The parsed JSON object
88
*/
9-
export function parseWithSchema<T>(json: string, schema: ZodSchema<T>): T {
9+
export function parseWithSchema<T extends z.ZodTypeAny>(
10+
json: string,
11+
schema: T
12+
): z.infer<T> {
1013
try {
1114
const jsonParsed = JSON.parse(json) as unknown;
12-
const zodParsed = schema.parse(jsonParsed);
13-
return zodParsed;
15+
// hits is fine to ignore
16+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
17+
return schema.parse(jsonParsed) as z.infer<T>;
1418
} catch (error) {
1519
if (error instanceof ZodError) {
1620
throw new Error(error.issues.map(prettyErrorMessage).join("\n"));

0 commit comments

Comments
 (0)